Improve baseline matching perf
We were previously calling PathVariables.toPathStringIfMatched to
substitute path variables inside a loop, which can be very expensive,
was somewhat unnecessary, and included redundant re-computation. This
change makes it so we just look for a specific path variable prefix, and
avoid re-computation (but the code is less concise).
Bug: 336329409
Test: some new unit tests, plus existing
Change-Id: Ie7c73d2adce848ae6617f11ff3c7fe3b8241d161
diff --git a/lint/libs/lint-api/src/main/java/com/android/tools/lint/client/api/LintBaseline.kt b/lint/libs/lint-api/src/main/java/com/android/tools/lint/client/api/LintBaseline.kt
index 8db4591..db74cc2 100644
--- a/lint/libs/lint-api/src/main/java/com/android/tools/lint/client/api/LintBaseline.kt
+++ b/lint/libs/lint-api/src/main/java/com/android/tools/lint/client/api/LintBaseline.kt
@@ -34,6 +34,7 @@
import com.android.tools.lint.detector.api.describeCounts
import com.android.utils.CharSequenceReader
import com.android.utils.XmlUtils.toXmlAttributeValue
+import com.google.common.annotations.VisibleForTesting
import com.google.common.collect.ArrayListMultimap
import com.google.common.collect.Lists
import com.google.common.collect.Maps
@@ -79,6 +80,19 @@
private val idToMessages = HashMap<String, MutableSet<String>>(30)
+ private val gradleCachePaths: List<String> = run {
+ val gradleHome = client.pathVariables["GRADLE_USER_HOME"] ?: return@run emptyList()
+ val paths = mutableListOf<String>()
+ paths.add("${gradleHome.path}${File.separator}caches${File.separator}")
+ try {
+ val gradleHomeCanon = gradleHome.canonicalFile
+ if (gradleHome.path != gradleHomeCanon.path) {
+ paths.add("${gradleHomeCanon.path}${File.separator}caches${File.separator}")
+ }
+ } catch (_: Exception) {}
+ paths
+ }
+
/**
* Whether we should write the baseline file when the baseline is closed, if the baseline file
* doesn't already exist. We don't always do this because for example when lint is run from
@@ -279,6 +293,13 @@
val file = location.file
val path = file.path
+ // This code previously called PathVariables.toPathStringIfMatched on `path` and `entry.path` in
+ // order to see if they started with "$GRADLE_USER_HOME", within the loop below. But this has
+ // poor perf (and redundant re-computation for `path`) when there are many entries and many path
+ // variables. So we instead explicitly just look for the Gradle caches directory prefix, when
+ // necessary, and avoid re-computing this for `path` inside the loop.
+ val pathWithinGradleCaches =
+ lazy(LazyThreadSafetyMode.NONE) { tryGetPathWithinGradleCaches(path) }
val issueId = issue.id
for (entry in entries) {
entry ?: continue
@@ -287,7 +308,11 @@
IssueRegistry.isDeletedIssueId(entry.issueId) &&
IssueRegistry.getNewId(entry.issueId) == issueId
) {
- if (isSamePathSuffix(path, entry.path) || isSameGradleCachePath(client, path, entry.path)) {
+ if (
+ isSamePathSuffix(path, entry.path) ||
+ (pathWithinGradleCaches.value != null &&
+ isSimilarGradleCachePath(pathWithinGradleCaches.value!!, entry.path))
+ ) {
// Remove all linked entries. We don't loop through all the locations;
// they're allowed to vary over time, we just assume that all entries
// for the same warning should be cleared.
@@ -320,6 +345,61 @@
}
/**
+ * Returns true if [otherPath] is within the Gradle caches directory and both paths differ by 0 or
+ * 1 parts.
+ *
+ * For example:
+ * ```
+ * pathWithinGradleCaches : "transforms-3/111/transformed/lib-2.8.1/jars/classes.jar"
+ * otherPath: "/gradle_home/caches/transforms-3/222/transformed/lib-2.8.1/jars/classes.jar"
+ * ```
+ *
+ * would return true. See https://issuetracker.google.com/238892319
+ *
+ * [pathWithinGradleCaches] must have already been simplified via [tryGetPathWithinGradleCaches].
+ * [otherPath] should be a full path.
+ */
+ private fun isSimilarGradleCachePath(
+ pathWithinGradleCaches: CharSequence,
+ otherPath: String,
+ ): Boolean {
+ val otherPathWithinGradleCaches = tryGetPathWithinGradleCaches(otherPath) ?: return false
+ // Gradle cache paths have an ID somewhere in them that changes across test runs/machines
+ // Like transforms-3/ID/transformed/leakcanary-android-core-2.8.1/jars/classes.jar
+ // or artifacts-4/group/name/ID/jars/name-version.jar
+ // We don't want to be too tightly coupled to exactly where this ID shows up in the path,
+ // so we take the two paths and see if they differ by either zero or one directory names.
+
+ val chunks =
+ listOf(pathWithinGradleCaches, otherPathWithinGradleCaches).map { it.split("/", "\\") }
+ if (chunks[0].size != chunks[1].size) return false
+
+ var diffFound = false
+ for (i in chunks[0].indices) {
+ if (chunks[0][i] != chunks[1][i]) {
+ if (diffFound) return false
+ diffFound = true
+ }
+ }
+
+ return true
+ }
+
+ /**
+ * If [path] starts with the Gradle caches directory then returns [path] with that prefix removed
+ * (in other words, returns a relative path within the Gradle caches directory). Otherwise,
+ * returns null.
+ */
+ private fun tryGetPathWithinGradleCaches(path: String): CharSequence? {
+ for (gradleCachePath in gradleCachePaths) {
+ if (path.pathStartsWith(gradleCachePath)) {
+ return path.subSequence(gradleCachePath.length, path.length)
+ }
+ }
+ return null
+ }
+
+ /**
* Sometimes the exact message format for a given error shifts over time, for example when we
* decide to make it clearer. Since baselines are primarily matched by the error message, any
* format change would mean the recorded issue in the baseline no longer matches the error, and
@@ -758,42 +838,6 @@
}
}
- /**
- * If a path like
- * "$GRADLE_USER_HOME/caches/transforms-3/4366a02f2b10003dc48387e903833c2d/transformed/leakcanary-android-core-2.8.1/jars/classes.jar"
- * makes it into the baseline, we should compare the part of the path string inside the cache
- * instead of the whole path which can change on each test run/machine. See b/238892319
- */
- fun isSameGradleCachePath(client: LintClient, path1: String, path2: String): Boolean {
- val gradleCachePath = "${"$"}GRADLE_USER_HOME/caches/"
- val cacheRelativePaths =
- listOf(path1, path2).map { path ->
- // First we see if the path lies inside $GRADLE_USER_HOME/caches/
- val pathWithPathVariables =
- client.pathVariables.toPathStringIfMatched(path) ?: return false
- if (!pathWithPathVariables.startsWith(gradleCachePath)) return false
- pathWithPathVariables.removePrefix(gradleCachePath)
- }
-
- // Gradle cache paths have an ID somewhere in them that changes across test runs/machines
- // Like caches/transforms-3/ID/transformed/leakcanary-android-core-2.8.1/jars/classes.jar
- // or caches/artifacts-4/group/name/ID/jars/name-version.jar
- // We don't want to be too tightly coupled to exactly where this ID shows up in the path,
- // so we take the two paths and see if they differ by either zero or one directory names.
- val chunks = cacheRelativePaths.map { it.split("/") }
- if (chunks[0].size != chunks[1].size) return false
-
- var diffFound = false
- for (i in chunks[0].indices) {
- if (chunks[0][i] != chunks[1][i]) {
- if (diffFound) return false
- diffFound = true
- }
- }
-
- return true
- }
-
/** Like path.endsWith(suffix), but considers \\ and / identical. */
fun isSamePathSuffix(path: String, suffix: String): Boolean {
var i = path.length - 1
@@ -833,6 +877,45 @@
return true
}
+ /**
+ * Similar to String.startsWith, but considers / and \ identical, and returns false if the last
+ * part of [prefix] is only a prefix of the corresponding part in [this].
+ *
+ * For example, "/abc/de" is not a prefix of "/abc/def".
+ */
+ @VisibleForTesting
+ fun String.pathStartsWith(prefix: String): Boolean {
+ // Prefix cannot be longer.
+ if (prefix.length > this.length) return false
+ if (prefix.isEmpty()) return true
+
+ // From 0 to prefix.length, all characters must match.
+ // Convert \ to / before comparing.
+ var i = 0
+ while (i < prefix.length) {
+ var p = prefix[i]
+ if (p == '\\') p = '/'
+
+ var t = this[i]
+ if (t == '\\') t = '/'
+
+ if (p != t) return false
+
+ ++i
+ }
+
+ // We could return true, but we avoid returning true for partial parts:
+ // this: /abc/def
+ // prefix: /abc/de
+
+ // If prefix ends in / then we are done.
+ if (prefix[i - 1] == '/' || prefix[i - 1] == '\\') return true
+ // If same length then done.
+ if (prefix.length == this.length) return true
+ // Otherwise, next character must be /.
+ return this[i] == '/' || this[i] == '\\'
+ }
+
private fun getDisplayPath(client: LintClient, project: Project?, file: File): String {
var path = file.path
if (project == null) {
diff --git a/lint/libs/lint-tests/src/test/java/com/android/tools/lint/client/api/LintBaselineTest.kt b/lint/libs/lint-tests/src/test/java/com/android/tools/lint/client/api/LintBaselineTest.kt
index 47df044..fedf3fd 100644
--- a/lint/libs/lint-tests/src/test/java/com/android/tools/lint/client/api/LintBaselineTest.kt
+++ b/lint/libs/lint-tests/src/test/java/com/android/tools/lint/client/api/LintBaselineTest.kt
@@ -54,6 +54,7 @@
import com.android.tools.lint.checks.infrastructure.TestMode
import com.android.tools.lint.checks.infrastructure.dos2unix
import com.android.tools.lint.client.api.LintBaseline.Companion.isSamePathSuffix
+import com.android.tools.lint.client.api.LintBaseline.Companion.pathStartsWith
import com.android.tools.lint.client.api.LintBaseline.Companion.prefixMatchLength
import com.android.tools.lint.client.api.LintBaseline.Companion.sameWithAbsolutePath
import com.android.tools.lint.client.api.LintBaseline.Companion.stringsEquivalent
@@ -280,6 +281,40 @@
}
@Test
+ fun testPrefix() {
+ assertTrue("".pathStartsWith(""))
+
+ assertFalse("123".pathStartsWith("1234"))
+ assertTrue("1234".pathStartsWith("1234"))
+ assertTrue("abc/def".pathStartsWith("abc"))
+ assertFalse("abc/def".pathStartsWith("ab"))
+
+ assertTrue("/abc/def".pathStartsWith(""))
+ assertTrue("/abc/def".pathStartsWith("/"))
+ assertTrue("/abc/def".pathStartsWith("\\"))
+ assertTrue("\\abc/def".pathStartsWith("/"))
+
+ assertFalse("/abc/def".pathStartsWith("/ab"))
+
+ assertTrue("/abc/def".pathStartsWith("/abc"))
+ assertTrue("/abc/def".pathStartsWith("\\abc"))
+ assertTrue("\\abc/def".pathStartsWith("/abc"))
+ assertTrue("\\abc\\def".pathStartsWith("/abc"))
+
+ assertFalse("/abc/def".pathStartsWith("/def"))
+
+ assertFalse("/abc/def".pathStartsWith("/abc/de"))
+
+ assertTrue("/abc/def".pathStartsWith("/abc/def"))
+ assertTrue("/abc/def".pathStartsWith("/abc\\def"))
+ assertTrue("/abc\\def".pathStartsWith("/abc/def"))
+ assertFalse("/abc/def".pathStartsWith("/abc/dee"))
+
+ assertTrue("/abc/def/".pathStartsWith("/abc/def"))
+ assertTrue("/abc/def\\".pathStartsWith("/abc/def"))
+ }
+
+ @Test
fun testStringsEquivalent() {
assertTrue(stringsEquivalent("", ""))
assertTrue(stringsEquivalent("foo", ""))