blob: 00131e52cb2a51d24c772871127b8d3bbb22762b [file] [log] [blame]
/*
* Copyright (C) 2011 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.detector.api
import com.android.SdkConstants.DOT_GRADLE
import com.android.SdkConstants.DOT_JAVA
import com.android.SdkConstants.DOT_XML
import com.android.SdkConstants.SUPPRESS_ALL
import com.android.tools.lint.client.api.Configuration
import com.android.tools.lint.client.api.LintClient
import com.android.tools.lint.client.api.LintDriver
import com.android.tools.lint.client.api.LintDriver.Companion.STUDIO_ID_PREFIX
import com.android.tools.lint.client.api.SdkInfo
import com.android.utils.CharSequences
import com.android.utils.CharSequences.indexOf
import com.google.common.annotations.Beta
import com.google.common.annotations.VisibleForTesting
import com.intellij.psi.PsiElement
import org.jetbrains.uast.UAnnotated
import org.jetbrains.uast.UElement
import org.w3c.dom.Node
import java.io.File
import java.util.EnumSet
/**
* Context passed to the detectors during an analysis run. It provides
* information about the file being analyzed, it allows shared properties (so
* the detectors can share results), etc.
*
**NOTE: This is not a public or final API; if you rely on this be prepared
* to adjust your code for the next tools release.**
*/
@Beta
open class Context(
/** The driver running through the checks */
val driver: LintDriver,
/** The project containing the file being checked */
val project: Project,
/**
* The "main" project. For normal projects, this is the same as [.project],
* but for library projects, it's the root project that includes (possibly indirectly)
* the various library projects and their library projects.
*
* Note that this is a property on the [Context], not the
* [Project], since a library project can be included from multiple
* different top level projects, so there isn't **one** main project,
* just one per main project being analyzed with its library projects.
*/
main: Project?,
/**
* The file being checked. Note that this may not always be to a concrete
* file. For example, in the [Detector.beforeCheckProject]
* method, the context file is the directory of the project.
*/
@JvmField
val file: File,
/** The contents of the file */
private var contents: CharSequence? = null
) {
/** The current configuration controlling which checks are enabled etc */
val configuration: Configuration
/** Whether this file contains any suppress markers (null means not yet determined) */
private var containsCommentSuppress: Boolean? = null
init {
configuration = project.getConfiguration(driver)
}
/**
* The scope for the lint job
*/
val scope: EnumSet<Scope>
get() = driver.scope
/**
* Returns the main project if this project is a library project, or self
* if this is not a library project. The main project is the root project
* of all library projects, not necessarily the directly including project.
*
* @return the main project, never null
*/
val mainProject: Project = main ?: project
/**
* The lint client requesting the lint check
*/
val client: LintClient
get() = driver.client
/**
* Returns the contents of the file. This may not be the contents of the
* file on disk, since it delegates to the [LintClient], which in turn
* may decide to return the current edited contents of the file open in an
* editor.
*
* @return the contents of the given file, or null if an error occurs.
*/
open fun getContents(): CharSequence? {
if (contents == null) {
contents = driver.client.readFile(file)
}
return contents
}
/**
* Gets the SDK info for the current project.
*
* @return the SDK info for the current project, never null
*/
val sdkInfo: SdkInfo
get() = project.getSdkInfo()
// ---- Convenience wrappers ---- (makes the detector code a bit leaner)
/**
* Returns false if the given issue has been disabled. Convenience wrapper
* around [Configuration.getSeverity].
*
* @param issue the issue to check
*
* @return false if the issue has been disabled
*/
fun isEnabled(issue: Issue): Boolean = configuration.isEnabled(issue)
/**
* Reports an issue. Convenience wrapper around [LintClient.report]
*
* @param issue the issue to report
*
* @param location the location of the issue
*
* @param message the message for this warning
*
* @param quickfixData parameterized data for IDE quickfixes
*/
@JvmOverloads
open fun report(
issue: Issue,
location: Location,
message: String,
quickfixData: LintFix? = null
) {
// See if we actually have an associated source for this location, and if so
// check to see if the warning might be suppressed.
val source = location.source
if (source is Node) {
// Also see if we have the context for this location (e.g. code could
// have directly called XmlContext/JavaContext report methods instead); this
// is better because the context also checks for issues suppressed via comment
if (this is XmlContext) {
if (source.ownerDocument === this.document) {
this.report(issue, source, location, message, quickfixData)
return
}
}
if (driver.isSuppressed(null, issue, source)) {
return
}
} else if (source is PsiElement) {
// Check for suppressed issue via location node
if (this is JavaContext) {
val javaContext = this
if (source.containingFile == javaContext.psiFile) {
javaContext.report(issue, source, location, message, quickfixData)
return
}
}
if (driver.isSuppressed(null, issue, source)) {
return
}
} else if (source is UElement) {
val element = source.psi
if (element != null && this is JavaContext) {
val javaContext = this
if (element.containingFile == javaContext.psiFile) {
javaContext.report(issue, source, location, message, quickfixData)
return
}
}
if (element is UAnnotated) {
if (driver.isSuppressed(null, issue, element as UAnnotated)) {
return
}
} else if (driver.isSuppressed(null, issue, element)) {
return
}
}
doReport(issue, location, message, quickfixData)
}
@Deprecated(
"Here for temporary compatibility; the new typed quickfix data parameter" +
" should be used instead", ReplaceWith("report(issue, location, message)")
)
fun report(
issue: Issue,
location: Location,
message: String,
quickfixData: Any?
) = report(issue, location, message)
// Method not callable outside of the lint infrastructure: perform the actual reporting.
// This is a separate method instead of just having Context#report() do this work,
// since Context#report() will possibly redirect to the XmlContext or JavaContext reporting
// mechanisms if it discovers that it's been called on the wrong node.
protected fun doReport(
issue: Issue,
location: Location,
message: String,
quickfixData: LintFix?
) {
@Suppress("SENSELESS_COMPARISON")
if (location == null) {
// Misbehaving third-party lint detectors, called from Java
assert(false) { issue }
return
}
if (location === Location.NONE) {
// Detector reported error for issue in a non-applicable location etc
return
}
var configuration = this.configuration
// If this error was computed for a context where the context corresponds to
// a project instead of a file, the actual error may be in a different project (e.g.
// a library project), so adjust the configuration as necessary.
val project = driver.findProjectFor(location.file)
if (project != null) {
configuration = project.getConfiguration(driver)
}
// If an error occurs in a library project, but you've disabled that check in the
// main project, disable it in the library project too. (In some cases you don't
// control the lint.xml of a library project, and besides, if you're not interested in
// a check for your main project you probably don't care about it in the library either.)
if (configuration !== this.configuration && this.configuration.getSeverity(issue) === Severity.IGNORE) {
return
}
val severity = configuration.getSeverity(issue)
if (severity === Severity.IGNORE) {
return
}
driver.client.report(
this, issue, severity, location, message, TextFormat.RAW,
quickfixData
)
}
/**
* Send an exception to the log. Convenience wrapper around [LintClient.log].
*
* @param exception the exception, possibly null
*
* @param format the error message using [java.lang.String.format] syntax, possibly null
*
* @param args any arguments for the format string
*/
fun log(
exception: Throwable?,
format: String?,
vararg args: Any
) = driver.client.log(exception, format, *args)
/**
* Returns the current phase number. The first pass is numbered 1. Only one pass
* will be performed, unless a [Detector] calls [.requestRepeat].
*
* @return the current phase, usually 1
*/
val phase: Int
get() = driver.phase
/**
* Requests another pass through the data for the given detector. This is
* typically done when a detector needs to do more expensive computation,
* but it only wants to do this once it **knows** that an error is
* present, or once it knows more specifically what to check for.
*
* @param detector the detector that should be included in the next pass.
* Note that the lint runner may refuse to run more than a couple
* of runs.
*
* @param scope the scope to be revisited. This must be a subset of the
* current scope ([.getScope], and it is just a performance hint;
* in particular, the detector should be prepared to be called on other
* scopes as well (since they may have been requested by other detectors).
* You can pall null to indicate "all".
*/
fun requestRepeat(detector: Detector, scope: EnumSet<Scope>?) =
driver.requestRepeat(detector, scope)
/** Returns the comment marker used in Studio to suppress statements for language, if any */
protected open val suppressCommentPrefix: String?
get() {
val path = file.path
if (path.endsWith(DOT_JAVA) || path.endsWith(DOT_GRADLE)) {
return SUPPRESS_JAVA_COMMENT_PREFIX
} else if (path.endsWith(DOT_XML)) {
return SUPPRESS_XML_COMMENT_PREFIX
} else if (path.endsWith(".cfg") || path.endsWith(".pro")) {
return "#suppress "
}
return null
}
/** Returns whether this file contains any suppress comment markers */
fun containsCommentSuppress(): Boolean {
if (containsCommentSuppress == null) {
containsCommentSuppress = false
val prefix = suppressCommentPrefix
if (prefix != null) {
val contents = getContents()
if (contents != null) {
containsCommentSuppress = indexOf(contents, prefix) != -1
}
}
}
return containsCommentSuppress!!
}
/**
* Returns true if the given issue is suppressed at the given character offset
* in the file's contents
*/
fun isSuppressedWithComment(startOffset: Int, issue: Issue): Boolean {
val prefix = suppressCommentPrefix ?: return false
if (startOffset <= 0) {
return false
}
// Check whether there is a comment marker
val contents: CharSequence = getContents() ?: ""
if (startOffset >= contents.length) {
return false
}
// Scan backwards to the previous line and see if it contains the marker
val lineStart = contents.lastIndexOf('\n', startOffset) + 1
if (lineStart <= 1) {
return false
}
val index = findPrefixOnPreviousLine(contents, lineStart, prefix)
if (index != -1 && index + prefix.length < lineStart) {
val line = contents.subSequence(index + prefix.length, lineStart).toString()
return isSuppressedWithComment(line, issue)
}
return false
}
companion object {
@VisibleForTesting
fun isSuppressedWithComment(line: String, issue: Issue): Boolean {
return lineContainsId(line, issue.id) ||
lineContainsId(line, SUPPRESS_ALL) ||
isSuppressedWithComment(line, issue.category)
}
private fun isSuppressedWithComment(line: String, category: Category): Boolean {
return lineContainsId(line, category.name) ||
category.parent != null &&
(lineContainsId(line, category.fullName) ||
isSuppressedWithComment(line, category.parent))
}
// Like line.contains(id), but requires word match (e.g. "MyId" is found
// in "SomeId,MyId" but not in "NotMyId")
private fun lineContainsId(line: String, id: String): Boolean {
var index = 0
while (index < line.length) {
index = line.indexOf(id, startIndex = index, ignoreCase = true)
if (index == -1) {
return false
}
if (isWord(line, id, index)) {
return true
}
index += id.length
}
return false
}
private fun isWord(line: String, word: String, index: Int): Boolean {
val end = index + word.length
if (end < line.length && !isWordDelimiter(line[end])) {
return false
}
if (index > 0 && !isWordDelimiter(line[index - 1])) {
// See if it's prefixed by "AndroidLint"; as a special case we allow
// that since in the IDE issues are often prefixed by both
val prefixStart = index - STUDIO_ID_PREFIX.length
if (index >= STUDIO_ID_PREFIX.length &&
line.regionMatches(
prefixStart, STUDIO_ID_PREFIX,
0, STUDIO_ID_PREFIX.length
) && (prefixStart == 0 || isWordDelimiter(line[prefixStart - 1]))
) {
return true
}
return false
}
return true
}
private fun isWordDelimiter(c: Char): Boolean = !c.isJavaIdentifierPart()
private fun findPrefixOnPreviousLine(
contents: CharSequence,
lineStart: Int,
prefix: String
): Int {
// Search backwards on the previous line until you find the prefix start (also look
// back on previous lines if the previous line(s) contain just whitespace
val first = prefix[0]
var offset = lineStart - 2 // 0: first char on this line, -1: \n on previous line, -2 last
var seenNonWhitespace = false
while (offset >= 0) {
val c = contents[offset]
if (seenNonWhitespace && c == '\n') {
return -1
}
if (!seenNonWhitespace && !Character.isWhitespace(c)) {
seenNonWhitespace = true
}
if (c == first && CharSequences.regionMatches(
contents, offset, prefix, 0,
prefix.length
)
) {
return offset
}
offset--
}
return -1
}
}
}