blob: cd2b34bbb1ec2a47ec5aa7c1bf7457be4a1b067d [file] [log] [blame]
/*
* Copyright (C) 2014 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.lint.checks
import com.android.tools.lint.client.api.TYPE_STRING
import com.android.tools.lint.detector.api.Category
import com.android.tools.lint.detector.api.ConstantEvaluator
import com.android.tools.lint.detector.api.Detector
import com.android.tools.lint.detector.api.Implementation
import com.android.tools.lint.detector.api.Issue
import com.android.tools.lint.detector.api.JavaContext
import com.android.tools.lint.detector.api.Scope
import com.android.tools.lint.detector.api.Severity
import com.android.tools.lint.detector.api.SourceCodeScanner
import com.intellij.psi.PsiMethod
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.UExpression
import org.jetbrains.uast.ULiteralExpression
import org.jetbrains.uast.UPolyadicExpression
import org.jetbrains.uast.UastBinaryOperator
import org.jetbrains.uast.expressions.UInjectionHost
/** Checks for errors related to Date Formats */
class DateFormatDetector : Detector(), SourceCodeScanner {
// ---- implements SourceCodeScanner ----
override fun getApplicableConstructorTypes(): List<String>? {
return listOf(JAVA_SIMPLE_DATE_FORMAT_CLS, ICU_SIMPLE_DATE_FORMAT_CLS)
}
override fun visitConstructor(
context: JavaContext,
node: UCallExpression,
constructor: PsiMethod
) {
if (!specifiesLocale(constructor)) {
val location = context.getLocation(node)
val message = "To get local formatting use `getDateInstance()`, " +
"`getDateTimeInstance()`, or `getTimeInstance()`, or use " +
"`new SimpleDateFormat(String template, Locale locale)` with for " +
"example `Locale.US` for ASCII dates."
context.report(DATE_FORMAT, node, location, message)
}
if (context.isEnabled(WEEK_YEAR)) {
checkDateFormat(context, node)
}
}
override fun getApplicableMethodNames(): List<String>? {
return listOf("ofPattern")
}
override fun visitMethodCall(context: JavaContext, node: UCallExpression, method: PsiMethod) {
if (context.evaluator.isMemberInClass(method, CLS_DATE_TIME_FORMATTER) &&
context.isEnabled(WEEK_YEAR)
) {
checkDateFormat(context, node)
}
}
private fun checkDateFormat(context: JavaContext, call: UCallExpression) {
for (argument in call.valueArguments) {
val type = argument.getExpressionType() ?: continue
if (type.canonicalText == TYPE_STRING) {
checkDateFormat(context, argument)
break
}
}
}
private fun checkDateFormat(context: JavaContext, argument: UExpression) {
val value = when (argument) {
is ULiteralExpression -> argument.value
is UInjectionHost -> argument.evaluateToString()
?: ConstantEvaluator().allowUnknowns().evaluate(argument)
?: return
else -> ConstantEvaluator().allowUnknowns().evaluate(argument) ?: return
}
val format = value.toString()
if (!format.contains("Y")) {
return
}
var escaped = false
var weekYearStart = -1
var haveDate = false
var haveEraYear = false
for ((index, c) in format.withIndex()) {
if (c == '\'') {
escaped = !escaped
} else if (c == 'M' || c == 'L' || c == 'd') {
haveDate = true
} else if (c == 'y' || c == 'u') {
haveEraYear = true
} else if (c == 'Y' && !escaped && weekYearStart == -1) {
weekYearStart = index
}
}
if (weekYearStart != -1 && haveDate && !haveEraYear) {
// The date string is referencing week-based years while also referencing
// months and/or days. That's probably a bug -- when you see week-based years
// that's typically combined with other week based quantities, like week-of-month.
val index = weekYearStart
var location = context.getLocation(argument)
var end = index + 1
while (end < format.length && format[end] == 'Y') {
end++
}
val digits = format.substring(index, end)
if (argument is ULiteralExpression) {
location = context.getRangeLocation(argument, index, end - index)
} else if (argument is UInjectionHost && argument is UPolyadicExpression &&
argument.operator == UastBinaryOperator.PLUS &&
argument.operands.size == 1 &&
argument.operands.first() is ULiteralExpression
) {
location = context.getRangeLocation(argument.operands[0], index, end - index)
}
context.report(
WEEK_YEAR, argument, location,
"`DateFormat` character 'Y' in $digits is the week-era-year; did you mean 'y' ?"
)
}
}
companion object {
private val IMPLEMENTATION =
Implementation(
DateFormatDetector::class.java,
Scope.JAVA_FILE_SCOPE
)
/** Constructing SimpleDateFormat without an explicit locale */
@JvmField
val DATE_FORMAT =
Issue.create(
id = "SimpleDateFormat",
briefDescription = "Implied locale in date format",
explanation = """
Almost all callers should use `getDateInstance()`, `getDateTimeInstance()`, \
or `getTimeInstance()` to get a ready-made instance of SimpleDateFormat \
suitable for the user's locale. The main reason you'd create an instance \
this class directly is because you need to format/parse a specific \
machine-readable format, in which case you almost certainly want to \
explicitly ask for US to ensure that you get ASCII digits (rather than, \
say, Arabic digits).
Therefore, you should either use the form of the SimpleDateFormat \
constructor where you pass in an explicit locale, such as Locale.US, or \
use one of the get instance methods, or suppress this error if really know \
what you are doing.
""",
category = Category.CORRECTNESS,
priority = 6,
severity = Severity.WARNING,
moreInfo = "https://developer.android.com/reference/java/text/SimpleDateFormat.html",
implementation = IMPLEMENTATION
)
/** Accidentally(?) using week year instead of era year */
@JvmField
val WEEK_YEAR = Issue.create(
id = "WeekBasedYear",
briefDescription = "Week Based Year",
explanation = """
The `DateTimeFormatter` pattern `YYYY` returns the *week* based year, not \
the era-based year. This means that 12/29/2019 will format to 2019, but \
12/30/2019 will format to 2020!
If you expected this to format as 2019, you should use the pattern `yyyy` \
instead.
""",
moreInfo = "https://stackoverflow.com/questions/46847245/using-datetimeformatter-on-january-first-cause-an-invalid-year-value",
category = Category.I18N,
priority = 6,
severity = Severity.WARNING,
enabledByDefault = true,
implementation = IMPLEMENTATION
)
const val LOCALE_CLS = "java.util.Locale"
private const val CLS_DATE_TIME_FORMATTER = "java.time.format.DateTimeFormatter"
private const val JAVA_SIMPLE_DATE_FORMAT_CLS = "java.text.SimpleDateFormat"
private const val ICU_SIMPLE_DATE_FORMAT_CLS = "android.icu.text.SimpleDateFormat"
private fun specifiesLocale(method: PsiMethod): Boolean {
val parameterList = method.parameterList
val parameters = parameterList.parameters
for (parameter in parameters) {
val type = parameter.type
if (type.canonicalText == LOCALE_CLS) {
return true
}
}
return false
}
}
}