[go: nahoru, domu]

blob: 4ebdbfaa0bc88fb682d25e5a90e6313183e8f0d2 [file] [log] [blame]
/*
* Copyright 2019 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.
*/
@file:Suppress("UnstableApiUsage")
package androidx.build.lint
import com.android.tools.lint.detector.api.AnnotationInfo
import com.android.tools.lint.detector.api.AnnotationUsageInfo
import com.android.tools.lint.detector.api.AnnotationUsageType
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.LintFix
import com.android.tools.lint.detector.api.Location
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.android.tools.lint.detector.api.isKotlin
import com.intellij.psi.PsiField
import com.intellij.psi.PsiJavaFile
import com.intellij.psi.PsiMethod
import com.intellij.psi.PsiNewExpression
import com.intellij.psi.impl.source.tree.TreeElement
import org.jetbrains.kotlin.asJava.elements.KtLightMethod
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.kotlin.psi.psiUtil.endOffset
import org.jetbrains.uast.UAnnotation
import org.jetbrains.uast.UCallExpression
import org.jetbrains.uast.UElement
import org.jetbrains.uast.UExpression
import org.jetbrains.uast.ULiteralExpression
import org.jetbrains.uast.UParenthesizedExpression
import org.jetbrains.uast.UPolyadicExpression
import org.jetbrains.uast.UQualifiedReferenceExpression
import org.jetbrains.uast.USimpleNameReferenceExpression
class ReplaceWithDetector : Detector(), SourceCodeScanner {
override fun applicableAnnotations(): List<String> =
listOf(
JAVA_REPLACE_WITH_ANNOTATION,
KOTLIN_DEPRECATED_ANNOTATION,
)
override fun visitAnnotationUsage(
context: JavaContext,
element: UElement,
annotationInfo: AnnotationInfo,
usageInfo: AnnotationUsageInfo
) {
val qualifiedName = annotationInfo.qualifiedName
val annotation = annotationInfo.annotation
val referenced = usageInfo.referenced
val usage = usageInfo.usage
val type = usageInfo.type
// Ignore callbacks for assignment on the original declaration of an annotated field.
if (type == AnnotationUsageType.ASSIGNMENT_RHS && usage.uastParent == referenced) return
// [b/323214452] Don't replace property usages since we don't handle property accessors.
if ((referenced as? KtLightMethod)?.kotlinOrigin is KtProperty) return
// Don't warn for Kotlin replacement in Kotlin files -- that's the Kotlin Compiler's job.
if (qualifiedName == KOTLIN_DEPRECATED_ANNOTATION && isKotlin(usage.lang)) return
var (expression, imports) =
when (qualifiedName) {
KOTLIN_DEPRECATED_ANNOTATION -> {
val replaceWith =
annotation.findAttributeValue("replaceWith")?.unwrap() as? UCallExpression
?: return
val expression =
replaceWith.valueArguments.getOrNull(0)?.parseLiteral() ?: return
val imports =
replaceWith.valueArguments.getOrNull(1)?.parseVarargLiteral() ?: emptyList()
Pair(expression, imports)
}
JAVA_REPLACE_WITH_ANNOTATION -> {
val expression =
annotation.findAttributeValue("expression")?.let { expr ->
ConstantEvaluator.evaluate(context, expr)
} as? String ?: return
val imports = annotation.getAttributeValueVarargLiteral("imports")
Pair(expression, imports)
}
else -> return
}
var location = context.getLocation(usage)
val includeReceiver = Regex("^\\w+\\.\\w+.*\$").matches(expression)
val includeArguments = Regex("^.*\\w+\\(.*\\)$").matches(expression)
if (referenced is PsiMethod && usage is UCallExpression) {
// Per Kotlin documentation for ReplaceWith: For function calls, the replacement
// expression may contain argument names of the deprecated function, which will
// be substituted with actual parameters used in the call being updated.
val argsToParams =
referenced.parameters
.mapIndexed { index, param ->
param.name to usage.getArgumentForParameter(index)?.asSourceString()
}
.toMap()
// Tokenize the replacement expression using a regex, replacing as we go. This
// isn't the most efficient approach (e.g. trie) but it's easy to write.
val search = Regex("\\w+")
var index = 0
do {
val matchResult = search.find(expression, index) ?: break
val replacement = argsToParams[matchResult.value]
if (replacement != null) {
expression = expression.replaceRange(matchResult.range, replacement)
index += replacement.length
} else {
index += matchResult.value.length
}
} while (index < expression.length)
location =
when (val sourcePsi = usage.sourcePsi) {
is PsiNewExpression -> {
// The expression should never specify "new", but if it specifies a
// receiver then we should replace the call to "new". For example, if
// we're replacing `new Clazz("arg")` with `ClazzCompat.create("arg")`.
context.getConstructorLocation(
usage,
sourcePsi,
includeReceiver,
includeArguments
)
}
else -> {
// The expression may optionally specify a receiver or arguments, in
// which case we should include the originals in the replacement range.
context.getCallLocation(usage, includeReceiver, includeArguments)
}
}
} else if (referenced is PsiField && usage is USimpleNameReferenceExpression) {
// The expression may optionally specify a receiver, in which case we should
// include the original in the replacement range.
if (includeReceiver) {
// If this is a qualified reference and we're including the "receiver" then
// we should replace the fully-qualified expression.
(usage.uastParent as? UQualifiedReferenceExpression)?.let { reference ->
location = context.getLocation(reference)
}
}
}
reportLintFix(context, usage, location, expression, imports)
}
private fun reportLintFix(
context: JavaContext,
usage: UElement,
location: Location,
expression: String,
imports: List<String>,
) {
context.report(
ISSUE,
usage,
location,
"Replacement available",
createLintFix(context, location, expression, imports)
)
}
private fun createLintFix(
context: JavaContext,
location: Location,
expression: String,
imports: List<String>
): LintFix {
val name = "Replace with `$expression`"
val lintFixBuilder = fix().composite().name(name)
lintFixBuilder.add(fix().replace().range(location).name(name).with(expression).build())
if (imports.isNotEmpty()) {
lintFixBuilder.add(fix().import(context, add = imports).build())
}
return lintFixBuilder.build()
}
/**
* Add imports.
*
* @return a string replace builder
*/
fun LintFix.Builder.import(
context: JavaContext,
add: List<String>
): LintFix.ReplaceStringBuilder {
val isKotlin = isKotlin(context.uastFile!!.lang)
val lastImport = context.uastFile?.imports?.lastOrNull()
val packageElem =
when (val psiFile = context.psiFile) {
is PsiJavaFile -> psiFile.packageStatement
is KtFile -> psiFile.packageDirective?.psiOrParent
else -> null
}
// Build the imports block. Leave any ordering or formatting up to the client.
val prependImports =
when {
lastImport != null -> "\n"
packageElem != null -> "\n\n"
else -> ""
}
val appendImports =
when {
lastImport != null -> ""
packageElem != null -> ""
else -> "\n"
}
val formattedImports = add.joinToString("\n") { "import " + if (isKotlin) it else "$it;" }
val importsText = prependImports + formattedImports + appendImports
// Append after any existing imports, after the package declaration, or at the beginning of
// the file if there are no imports and no package declaration.
val appendLocation =
(lastImport ?: packageElem)?.let { context.getLocation(it) }
?: Location.create(context.file, context.getContents(), 0, 0)
val replaceBuilder = replace().range(appendLocation).end().with(importsText)
return replaceBuilder.autoFix()
}
companion object {
private val IMPLEMENTATION =
Implementation(
ReplaceWithDetector::class.java,
Scope.JAVA_FILE_SCOPE,
)
const val KOTLIN_DEPRECATED_ANNOTATION = "kotlin.Deprecated"
const val JAVA_REPLACE_WITH_ANNOTATION = "androidx.annotation.ReplaceWith"
val ISSUE =
Issue.create(
id = "ReplaceWith",
briefDescription = "Replacement available",
explanation = "A recommended replacement is available for this API usage.",
category = Category.CORRECTNESS,
priority = 4,
severity = Severity.INFORMATIONAL,
implementation = IMPLEMENTATION,
)
}
}
/**
* Modified version of [JavaContext.getRangeLocation] that uses the `classReference` instead of the
* `receiver` to handle trimming the `new` keyword from the start of a Java constructor call.
*/
fun JavaContext.getConstructorLocation(
call: UCallExpression,
newExpression: PsiNewExpression,
includeNew: Boolean,
includeArguments: Boolean
): Location {
if (includeArguments) {
call.valueArguments.lastOrNull()?.let { lastArgument ->
val argumentsEnd = lastArgument.sourcePsi?.endOffset
val callEnds = newExpression.endOffset
if (argumentsEnd != null && argumentsEnd > callEnds) {
// The call element has arguments that are outside of its own range.
// This typically means users are making a function call using
// assignment syntax, e.g. key = value instead of setKey(value);
// here the call range is just "key" and the arguments range is "value".
// Create a range which merges these two.
val startElement = if (!includeNew) call.classReference ?: call else call
// Work around UAST bug where the value argument list points directly to the
// string content node instead of a node containing the opening and closing
// tokens as well. We need to include the closing tags in the range as well!
val next =
(lastArgument.sourcePsi as? KtLiteralStringTemplateEntry)?.nextSibling
as? TreeElement
val delta =
if (next != null && next.elementType == KtTokens.CLOSING_QUOTE) {
next.textLength
} else {
0
}
return getRangeLocation(startElement, 0, lastArgument, delta)
}
}
}
val classReference = call.classReference
if (includeNew || classReference == null) {
if (includeArguments) {
// Method with arguments but no receiver is the default range for UCallExpressions
// modulo the scenario with arguments outside the call, handled at the beginning
// of this method
return getLocation(call)
}
// Just the method name
val methodIdentifier = call.methodIdentifier
if (methodIdentifier != null) {
return getLocation(methodIdentifier)
}
} else {
if (!includeArguments) {
val methodIdentifier = call.methodIdentifier
if (methodIdentifier != null) {
return getRangeLocation(classReference, 0, methodIdentifier, 0)
}
}
// Use PsiElement variant of getRangeLocation because UElement variant returns wrong results
// when the `from` argument starts after the `to` argument, as it does for the constructor
// class reference.
return getRangeLocation(classReference.javaPsi!!, 0, call.javaPsi!!, 0)
}
return getLocation(call)
}
/**
* @return the value of the specified vararg attribute as a list of String literals, or an empty
* list if not specified
*/
fun UAnnotation.getAttributeValueVarargLiteral(name: String): List<String> =
findDeclaredAttributeValue(name)?.parseVarargLiteral() ?: emptyList()
fun UExpression.parseVarargLiteral(): List<String> =
when (val expr = this.unwrap()) {
is ULiteralExpression -> listOfNotNull(expr.parseLiteral())
is UCallExpression -> expr.valueArguments.mapNotNull { it.parseLiteral() }
else -> emptyList()
}
fun UExpression.parseLiteral(): String? =
when (val expr = this.unwrap()) {
is ULiteralExpression -> expr.value.toString()
else -> null
}
fun UExpression.unwrap(): UExpression =
when (this) {
is UParenthesizedExpression -> expression.unwrap()
is UPolyadicExpression -> operands.singleOrNull()?.unwrap() ?: this
is UQualifiedReferenceExpression -> selector.unwrap()
else -> this
}