Skip to content

Commit 5da60c0

Browse files
committed
feat(lint): flag fui_ string resources that nothing in the module references
1 parent 7ed73c8 commit 5da60c0

3 files changed

Lines changed: 564 additions & 0 deletions

File tree

‎internal/lint/src/main/java/com/firebaseui/lint/internal/LintIssueRegistry.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class LintIssueRegistry : IssueRegistry() {
1212

1313
override val issues = listOf(
1414
NonGlobalIdDetector.NON_GLOBAL_ID,
15+
UnreferencedResourceDetector.UNREFERENCED_RESOURCE,
1516
UntranslatedResourceDetector.UNTRANSLATED_RESOURCE
1617
)
1718

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package com.firebaseui.lint.internal
2+
3+
import com.android.SdkConstants.ANDROID_MANIFEST_XML
4+
import com.android.SdkConstants.ATTR_NAME
5+
import com.android.SdkConstants.TAG_PLURALS
6+
import com.android.SdkConstants.TAG_STRING
7+
import com.android.SdkConstants.TOOLS_URI
8+
import com.android.ide.common.resources.configuration.FolderConfiguration
9+
import com.android.resources.ResourceFolderType
10+
import com.android.tools.lint.detector.api.Category
11+
import com.android.tools.lint.detector.api.Context
12+
import com.android.tools.lint.detector.api.Implementation
13+
import com.android.tools.lint.detector.api.Issue
14+
import com.android.tools.lint.detector.api.Location
15+
import com.android.tools.lint.detector.api.ResourceXmlDetector
16+
import com.android.tools.lint.detector.api.Scope
17+
import com.android.tools.lint.detector.api.Severity
18+
import com.android.tools.lint.detector.api.SourceCodeScanner
19+
import com.android.tools.lint.detector.api.XmlContext
20+
import com.android.tools.lint.detector.api.XmlScannerConstants
21+
import org.w3c.dom.Attr
22+
import org.w3c.dom.Element
23+
import java.util.EnumSet
24+
25+
/**
26+
* Flags a `fui_*` string or plurals resource that nothing in this module references.
27+
*
28+
* Android Lint's own `UnusedResources` does not report these, because for a library module it
29+
* cannot see the consumers that might use them. That blind spot is how 34 dead strings reached
30+
* `:auth`, carrying 2,603 translations across 84 locale folders, without the build ever saying
31+
* so: the module runs under `checkAllWarnings` with `warningsAsErrors` and still reported
32+
* nothing.
33+
*
34+
* The policy this encodes is deliberately narrower than `UnusedResources`, and is only correct
35+
* because of how this library is meant to be customised. `auth/README.md` documents these names
36+
* as ones a consuming app *overrides*, by declaring the same name in its own `strings.xml`. It
37+
* does not invite an app to read them through `R.string.`, and an override of a name the library
38+
* no longer declares is simply an app-owned string, so deleting one can neither break a consumer
39+
* build nor change behaviour the library never drove.
40+
*
41+
* Note that `:auth` ships no `res/values/public.xml`, so as far as AGP is concerned every one of
42+
* these resources is public API and this check is stricter than the build itself declares.
43+
* Declaring the intended surface in a `public.xml` would make the policy real rather than
44+
* conventional; until then this check is the only thing expressing it.
45+
*
46+
* Only names beginning `fui_` are considered. That is not much of a narrowing in practice, since
47+
* `auth/build.gradle.kts` sets `resourcePrefix("fui_")` and the built-in `ResourceName` check
48+
* then requires the prefix on everything the module declares. A resource that genuinely must be
49+
* exempt needs `tools:ignore="UnreferencedResource"` at its declaration, the same way `app_name`
50+
* carries `tools:ignore="ResourceName"`.
51+
*
52+
* ### What counts as a reference
53+
*
54+
* `R.string.name` and `R.plurals.name` in Kotlin and Java, and `@string/name` or `@plurals/name`
55+
* in any XML under `res/` or in `AndroidManifest.xml`, including an alias such as
56+
* `<string name="a">@string/b</string>`. Attributes in the `tools:` namespace are ignored, since
57+
* `tools:text="@string/x"` is design-time only and does not keep a resource alive at runtime.
58+
*
59+
* ### Known limits
60+
*
61+
* **Reports a resource used only by tests.** Test sources are not scanned, so a name referenced
62+
* from `src/test` or `src/androidTest` but nowhere in `src/main` is reported. That is deliberate:
63+
* a string no production code path reads is dead copy regardless of what a test does with it, and
64+
* the test should go with it. The message says "main sources" for that reason.
65+
*
66+
* **Cannot see other modules.** Lint runs this against `:auth` alone, so a `fui_*` resource that
67+
* only `:app` or a future sibling module referenced would be reported. Nothing outside `:auth`
68+
* declares or references a `fui_*` name today. If that changes, the reference needs
69+
* `tools:ignore` at the declaration, because a library module's lint genuinely cannot see it.
70+
*
71+
* The remaining limits all fail towards *not* reporting, so they cannot break a build over a
72+
* resource that is really in use. Source and manifest files are matched textually, so a reference
73+
* written through an aliased import (`import ...R as Res`, then `Res.string.name`) is not seen,
74+
* and a name appearing only in a comment counts as a reference; neither occurs in this repository
75+
* today. A resource reached only by [android.content.res.Resources.getIdentifier] is invisible
76+
* here, as it is to `UnusedResources`; there is no such call in the module. And a dead resource
77+
* that aliases another dead one keeps the second alive, so a chain like that is reported one link
78+
* per run rather than all at once.
79+
*/
80+
class UnreferencedResourceDetector : ResourceXmlDetector(), SourceCodeScanner {
81+
82+
/** Declarations, keyed by resource name. Locale folders translate, they do not declare. */
83+
private val declarations = mutableMapOf<String, Location.Handle>()
84+
85+
/** Every `fui_*` name referenced from source, the manifest, or XML anywhere in the module. */
86+
private val referenced = mutableSetOf<String>()
87+
88+
// Resources are referenced from every kind of folder, not just values/, so unlike
89+
// UntranslatedResourceDetector this one reads them all.
90+
override fun appliesTo(folderType: ResourceFolderType): Boolean = true
91+
92+
override fun getApplicableElements(): List<String> = XmlScannerConstants.ALL
93+
94+
override fun getApplicableAttributes(): List<String> = XmlScannerConstants.ALL
95+
96+
override fun visitElement(context: XmlContext, element: Element) {
97+
collectReferences(element.textContent)
98+
99+
val tag = element.tagName
100+
if (tag != TAG_STRING && tag != TAG_PLURALS) return
101+
102+
val folder = context.file.parentFile?.name ?: return
103+
if (!declaresResources(folder)) return
104+
105+
val name = element.getAttribute(ATTR_NAME)
106+
if (!name.startsWith(RESOURCE_PREFIX)) return
107+
108+
// First-wins, so the map does not depend on traversal order when the same name is
109+
// declared in both values/ and a configuration variant such as values-v26/.
110+
declarations.putIfAbsent(name, context.createLocationHandle(element))
111+
}
112+
113+
override fun visitAttribute(context: XmlContext, attribute: Attr) {
114+
// tools:text and friends are design-time only; they do not keep a resource alive.
115+
if (attribute.namespaceURI == TOOLS_URI) return
116+
collectReferences(attribute.value)
117+
}
118+
119+
override fun beforeCheckFile(context: Context) {
120+
// The manifest is not under res/, so ResourceXmlDetector never dispatches it to
121+
// visitAttribute. Reading it here is what stops android:label="@string/fui_x" from
122+
// looking like a dead resource and failing the build on correct code.
123+
val name = context.file.name
124+
val scannable = name.endsWith(".kt") || name.endsWith(".java") ||
125+
name == ANDROID_MANIFEST_XML
126+
if (!scannable) return
127+
collectReferences(context.getContents()?.toString() ?: return)
128+
}
129+
130+
override fun afterCheckRootProject(context: Context) {
131+
// Nothing is collected when lint runs over a single file, which is the IDE's incremental
132+
// mode, nor when it runs over a source set that declares no resources of its own, which
133+
// is how the unit-test and androidTest analysis passes behave. Reporting in either case
134+
// would call every resource dead.
135+
if (declarations.isNotEmpty()) {
136+
for ((name, handle) in declarations) {
137+
if (name in referenced) continue
138+
context.report(
139+
UNREFERENCED_RESOURCE,
140+
handle.resolve(),
141+
"\"$name\" is not referenced anywhere in this module's main sources, by " +
142+
"`R.string.`, `R.plurals.` or `@string/`. Delete it along with its " +
143+
"translations in every `values-*` folder, or mark it " +
144+
"`tools:ignore=\"$ISSUE_ID\"` if something reaches it in a way this " +
145+
"check cannot see."
146+
)
147+
}
148+
}
149+
150+
declarations.clear()
151+
referenced.clear()
152+
}
153+
154+
/**
155+
* Whether a `values` folder declares resources rather than translating them.
156+
*
157+
* Every locale folder repeats the base names, so treating one as a declaration site would
158+
* report a translation rather than the resource. A configuration variant such as `values-v26`
159+
* or `values-night` is not a translation, though, and a resource declared only there is just
160+
* as capable of being dead, so those do count.
161+
*/
162+
private fun declaresResources(folder: String): Boolean {
163+
if (folder == BASE_VALUES_FOLDER) return true
164+
if (!folder.startsWith("$BASE_VALUES_FOLDER-")) return false
165+
return FolderConfiguration.getConfigForFolder(folder)?.localeQualifier == null
166+
}
167+
168+
private fun collectReferences(text: String) {
169+
if (!text.contains(RESOURCE_PREFIX)) return
170+
for (pattern in REFERENCE_PATTERNS) {
171+
for (match in pattern.findAll(text)) {
172+
referenced += match.groupValues[1]
173+
}
174+
}
175+
}
176+
177+
companion object {
178+
private const val ISSUE_ID = "UnreferencedResource"
179+
private const val BASE_VALUES_FOLDER = "values"
180+
private const val RESOURCE_PREFIX = "fui_"
181+
182+
/** `R.string.fui_x` and `R.plurals.fui_x`, including a package-qualified `R`. */
183+
private val CODE_REFERENCE = Regex("""\bR\.(?:string|plurals)\.(fui_[A-Za-z0-9_]+)""")
184+
185+
/** `@string/fui_x` and `@plurals/fui_x`, in any XML attribute or text node. */
186+
private val XML_REFERENCE = Regex("""@(?:string|plurals)/(fui_[A-Za-z0-9_]+)""")
187+
188+
private val REFERENCE_PATTERNS = listOf(CODE_REFERENCE, XML_REFERENCE)
189+
190+
val UNREFERENCED_RESOURCE = Issue.create(
191+
ISSUE_ID,
192+
"Library string resource nothing references",
193+
"A `fui_*` string or plurals resource that this module never reads is dead copy. " +
194+
"It still ships, and it still reaches translators for every locale folder that " +
195+
"carries it. `UnusedResources` cannot report it, because for a library module " +
196+
"lint cannot see the consumers that might use the resource.",
197+
Category.PERFORMANCE,
198+
5,
199+
Severity.ERROR,
200+
Implementation(
201+
UnreferencedResourceDetector::class.java,
202+
EnumSet.of(Scope.ALL_RESOURCE_FILES, Scope.JAVA_FILE, Scope.MANIFEST)
203+
)
204+
)
205+
}
206+
}

0 commit comments

Comments
 (0)