Convert unused translations test to use a trie to more efficiently scan (#7263)

# Description of Changes
Unused translations test takes ~8 seconds to run on my computer with no
contention, but when my CPU is under heavy contention, it often takes
>20 seconds and occasionally goes over the 30 second timeout. This PR
changes the test to use [a trie](https://en.wikipedia.org/wiki/Trie) to
more efficiently search for the strings, taking the test down to ~1.8
seconds. I've also increased the timeout for the missing & unused
translations tests for belt-and-braces.
This commit is contained in:
James Brunton
2026-08-03 14:34:21 +00:00
committed by GitHub
parent cd199c8659
commit 19e7095a4f
3 changed files with 71 additions and 13 deletions
@@ -218,14 +218,15 @@ const extractStaticKeys = (file: string): MissingKey[] => {
file,
code,
ts.ScriptTarget.Latest,
true,
false,
getScriptKind(file),
);
const found: MissingKey[] = [];
const record = (node: ts.Node, key: string, fallback = "") => {
// Pass sourceFile explicitly: the tree is parsed without parent links.
const { line, character } = sourceFile.getLineAndCharacterOfPosition(
node.getStart(),
node.getStart(sourceFile),
);
found.push({ key, fallback, file, line: line + 1, column: character + 1 });
};
@@ -287,8 +288,11 @@ const extractStaticKeys = (file: string): MissingKey[] => {
* (not just t() sites; keys are often assembled in helpers/constants), but
* only kept if a shape carries an identifier-like static fragment.
*/
const extractTemplateShapes = (file: string, acc: Set<string>): void => {
const code = fs.readFileSync(file, "utf8");
const extractTemplateShapes = (
file: string,
code: string,
acc: Set<string>,
): void => {
if (!code.includes("${")) return;
const sourceFile = ts.createSourceFile(
@@ -319,6 +323,53 @@ const extractTemplateShapes = (file: string, acc: Set<string>): void => {
ts.forEachChild(sourceFile, visit);
};
interface TrieNode {
children: Map<number, TrieNode>;
needle?: string;
}
/**
* Which of `needles` appear as a substring of `text`.
*
* Same answer as `needles.filter((n) => text.includes(n))`, but walks the text
* once against a trie of every needle rather than once per needle: the locale
* carries thousands of keys and the joined source is tens of MB, so per-key
* scanning dominated the suite's runtime.
*/
const findSubstrings = (
text: string,
needles: Iterable<string>,
): Set<string> => {
const root: TrieNode = { children: new Map() };
for (const needle of needles) {
if (!needle) continue;
let node = root;
for (let i = 0; i < needle.length; i++) {
const code = needle.charCodeAt(i);
let next = node.children.get(code);
if (!next) {
next = { children: new Map() };
node.children.set(code, next);
}
node = next;
}
node.needle = needle;
}
const found = new Set<string>();
for (let start = 0; start < text.length; start++) {
let node = root.children.get(text.charCodeAt(start));
let at = start;
while (node) {
if (node.needle !== undefined) found.add(node.needle);
at += 1;
if (at >= text.length) break;
node = node.children.get(text.charCodeAt(at));
}
}
return found;
};
const shapeToMatcher = (shape: string): RegExp => {
// Each * is one runtime path segment, so match `[^.]+` (not `.+`) to avoid
// spanning key levels. Multi-segment interpolations use ignoredKeyPatterns.
@@ -363,19 +414,24 @@ export function findUnusedKeys(project: TranslationProject): {
} {
const localeKeys = Array.from(collectLocaleKeys(project.localeFile));
const files = listSourceFiles([project.srcRoot]);
const source = files.map((file) => fs.readFileSync(file, "utf8")).join("\n");
const contents = files.map((file) => fs.readFileSync(file, "utf8"));
const source = contents.join("\n");
const shapes = new Set<string>();
for (const file of files) extractTemplateShapes(file, shapes);
files.forEach((file, i) => extractTemplateShapes(file, contents[i], shapes));
const matchers = [...shapes].map(shapeToMatcher);
const patterns = project.ignoredKeyPatterns ?? [];
const unused = localeKeys.filter((key) => {
if (patterns.some((re) => re.test(key))) return false;
const candidates = localeKeys.filter(
(key) => !patterns.some((re) => re.test(key)),
);
// Direct: the literal appears anywhere in source (static t(), i18nKey,
// constants, comments). Plural variants count when their base is referenced.
const referenced = findSubstrings(source, candidates.flatMap(getLookupKeys));
const unused = candidates.filter((key) => {
const lookups = getLookupKeys(key);
// Direct: the literal appears anywhere in source (static t(), i18nKey,
// constants, comments). Plural variants count when their base is referenced.
if (lookups.some((k) => source.includes(k))) return false;
if (lookups.some((k) => referenced.has(k))) return false;
// Dynamic: the key matches a template-literal shape from source.
return !lookups.some((k) => matchers.some((re) => re.test(k)));
});
@@ -14,7 +14,8 @@ describe.each(I18N_PROJECTS)(
(project) => {
test(
"fails if any en-US key used in source is missing from the locale",
{ timeout: 10000 },
// Scans/parses the whole source tree: generous headroom for a loaded CPU.
{ timeout: 60_000 },
() => {
expect(fs.existsSync(project.localeFile)).toBe(true);
@@ -15,7 +15,8 @@ describe.each(I18N_PROJECTS)(
(project) => {
test(
"fails if any en-US key has no source references",
{ timeout: 30_000 },
// Scans/parses the whole source tree: generous headroom for a loaded CPU.
{ timeout: 60_000 },
() => {
expect(fs.existsSync(project.localeFile)).toBe(true);