From 5e7b0ccd31b7ae1fdb23ae9abd45541559f4b5ca Mon Sep 17 00:00:00 2001 From: MK Date: Thu, 5 Nov 2026 13:35:37 +0800 Subject: [PATCH] perf(lint): avoid repeated source copies in shared-utils rule --- oxlint-plugins/prefer-shared-utils.ts | 52 +++++++++++---------- tests/oxlint-prefer-shared-utils.test.ts | 57 ++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 23 deletions(-) diff --git a/oxlint-plugins/prefer-shared-utils.ts b/oxlint-plugins/prefer-shared-utils.ts index 3bb8db46c4..88b6e55694 100644 --- a/oxlint-plugins/prefer-shared-utils.ts +++ b/oxlint-plugins/prefer-shared-utils.ts @@ -108,27 +108,31 @@ const SHARED_UTILITY_DECLARATION = "g", ); -function maskRange(source: string, start: number, end: number) { - return `${source.slice(0, start)}${" ".repeat(end - start)}${source.slice(end)}`; -} - function maskCommentsAndQuotedStrings(source: string, options = { scanTemplateBodies: true }) { - let masked = source; + const parts: string[] = []; + let unmaskedStart = 0; + // Scan the original source and join once. Rebuilding the whole string for + // each span makes subsequent indexing repeatedly flatten it on large files. + function maskRange(start: number, end: number) { + parts.push(source.slice(unmaskedStart, start), " ".repeat(end - start)); + unmaskedStart = end; + } + let i = 0; - while (i < masked.length) { - const current = masked[i]; - const next = masked[i + 1]; + while (i < source.length) { + const current = source[i]; + const next = source[i + 1]; if (current === "/" && next === "/") { - const end = masked.indexOf("\n", i + 2); - const rangeEnd = end === -1 ? masked.length : end; - masked = maskRange(masked, i, rangeEnd); + const end = source.indexOf("\n", i + 2); + const rangeEnd = end === -1 ? source.length : end; + maskRange(i, rangeEnd); i = rangeEnd; continue; } if (current === "/" && next === "*") { - const end = masked.indexOf("*/", i + 2); - const rangeEnd = end === -1 ? masked.length : end + 2; - masked = maskRange(masked, i, rangeEnd); + const end = source.indexOf("*/", i + 2); + const rangeEnd = end === -1 ? source.length : end + 2; + maskRange(i, rangeEnd); i = rangeEnd; continue; } @@ -138,19 +142,19 @@ function maskCommentsAndQuotedStrings(source: string, options = { scanTemplateBo // fixtures as template strings; mask those like ordinary strings so the // lint rule validates the test file itself, not its fixture payloads. let end = i + 1; - while (end < masked.length) { - if (masked[end] === "\\") { + while (end < source.length) { + if (source[end] === "\\") { end += 2; continue; } - if (masked[end] === "`") { + if (source[end] === "`") { end += 1; break; } end += 1; } if (!options.scanTemplateBodies) { - masked = maskRange(masked, i, end); + maskRange(i, end); } i = end; continue; @@ -158,24 +162,26 @@ function maskCommentsAndQuotedStrings(source: string, options = { scanTemplateBo if (current === '"' || current === "'") { const quote = current; let end = i + 1; - while (end < masked.length) { - if (masked[end] === "\\") { + while (end < source.length) { + if (source[end] === "\\") { end += 2; continue; } - if (masked[end] === quote) { + if (source[end] === quote) { end += 1; break; } end += 1; } - masked = maskRange(masked, i, end); + maskRange(i, end); i = end; continue; } i += 1; } - return masked; + if (parts.length === 0) return source; + parts.push(source.slice(unmaskedStart)); + return parts.join(""); } function isCanonicalDefinitionFile(filename: string, modulePath: string) { diff --git a/tests/oxlint-prefer-shared-utils.test.ts b/tests/oxlint-prefer-shared-utils.test.ts index e1d8b804fc..feda9d47cc 100644 --- a/tests/oxlint-prefer-shared-utils.test.ts +++ b/tests/oxlint-prefer-shared-utils.test.ts @@ -176,6 +176,63 @@ void isUnknownRecord({}); expect(result.output).toContain("Use shared isUnknownRecord"); }, 30_000); + it("reports each helper once after many comments and escaped strings", () => { + const file = writeFixture( + "many-masked-spans.ts", + [ + "export const docs = [", + ...Array.from( + { length: 2_000 }, + () => String.raw`/* 😀 */ "escaped \" function isPromiseLike() {}", 'user\'s // docs',`, + ), + "].join('');", + "/* adjacent *//* comments */ function /* separator */ isPromiseLike(value: unknown) {", + " return value !== null;", + "}", + "export const generatedSource = `", + "The user's output contains /* comments */ and // text.", + "An escaped backtick: \\`", + "function isPromiseLike(value) { return value !== null; }", + "`;", + "// A declaration after the generated template must still be checked.", + "export const compareStrings = (left: string, right: string) => left.localeCompare(right);", + "void isPromiseLike(null);", + ].join("\n"), + ); + + const result = runLint([file]); + + expect(result.status).not.toBe(0); + expect(result.output.match(/Use shared isPromiseLike from/g)).toHaveLength(1); + expect(result.output.match(/Use shared compareStrings from/g)).toHaveLength(1); + }, 30_000); + + it("masks test templates and escaped quotes without hiding later declarations", () => { + const file = writeTestFixture( + "escaped-templates.test.ts", + [ + "export const fixture = `", + "The user's fixture contains /* and // and an escaped backtick: \\`", + "function isPromiseLike(value) { return value !== null; }", + "`;", + String.raw`export const doubleQuoted = "escaped \" function compareStrings() {}";`, + String.raw`export const singleQuoted = 'escaped \' function findFileWithExts() {}';`, + String.raw`export const backslash = "\\";`, + "// function isPromiseLike() {}", + "/* function compareStrings() {} */", + "export function isUnknownRecord(value: unknown) { return value !== null; }", + ].join("\r\n"), + ); + + const result = runLint([file]); + + expect(result.status).not.toBe(0); + expect(result.output.match(/Use shared isUnknownRecord from/g)).toHaveLength(1); + expect(result.output).not.toContain("Use shared isPromiseLike"); + expect(result.output).not.toContain("Use shared compareStrings"); + expect(result.output).not.toContain("Use shared findFileWithExts"); + }, 30_000); + it("allows canonical modules, comments, and ordinary strings", () => { const commentsAndStringsFile = writeFixture( "comments-and-strings.ts",