Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 29 additions & 23 deletions oxlint-plugins/prefer-shared-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -138,44 +142,46 @@ 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;
}
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) {
Expand Down
57 changes: 57 additions & 0 deletions tests/oxlint-prefer-shared-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading