fix(i18n): close Android scanner blind spots

This commit is contained in:
Vincent Koc
2026-07-12 12:07:29 +02:00
parent 422e6dc1e9
commit fdf1250ece
2 changed files with 80 additions and 11 deletions
+66 -11
View File
@@ -284,6 +284,9 @@ async function readAndroidResourceReferences(root = ANDROID_MAIN_ROOT): Promise<
sources.push(await readAndroidResourceReferences(fullPath));
continue;
}
if (fullPath === GENERATED_KOTLIN_PATH) {
continue;
}
if (entry.isFile() && /\.(?:kt|kts|xml)$/u.test(entry.name)) {
sources.push(await readFile(fullPath, "utf8"));
}
@@ -295,11 +298,15 @@ export function findUnusedAndroidResourceKeys(
keys: Iterable<string>,
referenceSource: string,
): string[] {
const sourceWithoutComments = referenceSource
.replaceAll(/<!--[\s\S]*?-->/gu, "")
.replaceAll(/\/\*[\s\S]*?\*\//gu, "")
.replaceAll(/\/\/[^\r\n]*/gu, "");
const references = new Set([
...[...referenceSource.matchAll(/\bR\.string\.([A-Za-z0-9_]+)\b/gu)].flatMap((match) =>
...[...sourceWithoutComments.matchAll(/\bR\.string\.([A-Za-z0-9_]+)\b/gu)].flatMap((match) =>
match[1] ? [match[1]] : [],
),
...[...referenceSource.matchAll(/@string\/([A-Za-z0-9_]+)\b/gu)].flatMap((match) =>
...[...sourceWithoutComments.matchAll(/@string\/([A-Za-z0-9_]+)\b/gu)].flatMap((match) =>
match[1] ? [match[1]] : [],
),
]);
@@ -377,7 +384,7 @@ const UI_STRING_FUNCTION_RE = /\bfun\s+(?:<[^>{}]*>\s*)?([A-Za-z_][A-Za-z0-9_]*)
const UI_STRING_PROPERTY_RE =
/\bval\s+(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*(?:\n\s*)?get\(\)\s*=\s*/gu;
const UI_BRANCH_LITERAL_RE = /(?:->|return|\?:)\s*"((?:\\.|[^"\\])+)"/gu;
const UI_IF_BRANCH_LITERAL_RE = /(?:\bif\s*\([^)]*\)|\belse)\s*\{\s*"((?:\\.|[^"\\])+)"/gu;
const UI_ELSE_BRANCH_LITERAL_RE = /\belse\s*\{?\s*"((?:\\.|[^"\\])+)"/gu;
const UI_MODEL_STRING_NAME_RE =
/^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u;
const UI_MODEL_CLASS_FIELDS = new Map<string, ReadonlySet<string>>([
@@ -647,19 +654,51 @@ function collectHelperLiteralFindings(source: string, repoPath: string): Android
const matches = [
...(direct ? [direct] : []),
...body.matchAll(UI_BRANCH_LITERAL_RE),
...body.matchAll(UI_IF_BRANCH_LITERAL_RE),
...body.matchAll(UI_ELSE_BRANCH_LITERAL_RE),
];
const seenOffsets = new Set<number>();
const addLiteral = (literal: string, literalOffset: number) => {
if (seenOffsets.has(literalOffset)) {
return;
}
seenOffsets.add(literalOffset);
findings.push({
line: lineNumber(source, start + literalOffset),
path: repoPath,
source: decodeKotlinLiteral(literal),
});
};
for (const match of matches) {
const literal = match[1];
if (!literal) {
continue;
}
const literalOffset = body.indexOf(`"${literal}"`, match.index ?? 0);
findings.push({
line: lineNumber(source, start + Math.max(0, literalOffset)),
path: repoPath,
source: decodeKotlinLiteral(literal),
});
addLiteral(literal, Math.max(0, literalOffset));
}
for (const match of body.matchAll(/\bif\s*/gu)) {
let cursor = (match.index ?? 0) + match[0].length;
if (body[cursor] !== "(") {
continue;
}
const closingParen = findClosingDelimiter(body, cursor, "(", ")");
if (closingParen === null) {
continue;
}
cursor = closingParen + 1;
while (/\s/u.test(body[cursor] ?? "")) {
cursor += 1;
}
if (body[cursor] === "{") {
cursor += 1;
while (/\s/u.test(body[cursor] ?? "")) {
cursor += 1;
}
}
const literal = body.slice(cursor).match(/^"((?:\\.|[^"\\])+)"/u)?.[1];
if (literal) {
addLiteral(literal, cursor);
}
}
};
for (const match of source.matchAll(UI_STRING_FUNCTION_RE)) {
@@ -753,9 +792,25 @@ function collectTypedModelLiteralFindings(
if (userFacingParameters.size === 0) {
continue;
}
const callPattern = new RegExp(`\\b${className}\\s*\\(`, "gu");
const callPattern = new RegExp(`\\b${className}\\b`, "gu");
for (const call of source.matchAll(callPattern)) {
const callOpening = (call.index ?? 0) + call[0].lastIndexOf("(");
let callOpening = (call.index ?? 0) + call[0].length;
while (/\s/u.test(source[callOpening] ?? "")) {
callOpening += 1;
}
if (source[callOpening] === "<") {
const closingTypeArguments = findClosingDelimiter(source, callOpening, "<", ">");
if (closingTypeArguments === null) {
continue;
}
callOpening = closingTypeArguments + 1;
while (/\s/u.test(source[callOpening] ?? "")) {
callOpening += 1;
}
}
if (source[callOpening] !== "(") {
continue;
}
if (callOpening === openingParen) {
continue;
}
+14
View File
@@ -41,6 +41,20 @@ describe("Android app i18n resources", () => {
).toEqual(["native_status", "native_unused"]);
});
it("ignores Android resource references that only appear in comments", () => {
expect(
findUnusedAndroidResourceKeys(
["kotlin_comment", "block_comment", "xml_comment", "live"],
`
// R.string.kotlin_comment
/* R.string.block_comment */
<!-- @string/xml_comment -->
R.string.live
`,
),
).toEqual(["kotlin_comment", "block_comment", "xml_comment"]);
});
it("selects duplicate-source translations by frequency then stable text order", () => {
expect(selectDeterministicTranslation("Source", ["Beta", "Alpha", "Beta"])).toBe("Beta");
expect(selectDeterministicTranslation("Source", ["Beta", "Alpha"])).toBe("Alpha");