Files
overlookmotel ecd24560a1 fix(linter/plugins): handle fix with -1 offsets in file with BOM (#19092)
Fix an edge case bug where Oxlint was unable to handle fixes with with offset `-1`. Offset `-1` is used in files which contain a BOM to represent "before the BOM".

ESLint's `unicode-bom` rule produces a fix `{ range: [-1, 0], text: "" }` to remove BOM from a file with such a BOM.

`-1` can't be represented as a `u32`, so we have to take a rather annoying workaround - add 1 to offsets before sending them to Rust.

An alternative would have been to make `start` and `end` `i64`s so they can represent `-1`, but this would seem wasteful since we only need to represent -1, not any other negative value. So adding 1 to offsets is cheaper.

This fixes 2 ESLint conformance tests.
2026-02-08 20:05:57 +00:00

305 lines
9.5 KiB
TypeScript

import { sep as pathSep } from "node:path";
import assert from "node:assert";
import type { Diagnostic, Node, Plugin, Rule } from "#oxlint/plugins";
const U32_MAX_PLUS_ONE = 2 ** 32;
const rule: Rule = {
meta: {
fixable: "code",
},
create(context) {
// Check file has not been formatted by accident.
// We want the fixture files not to have trailing whitespace to check fixes at very end of file.
const sourceText = context.sourceCode.text;
assert(!sourceText.endsWith("\n"), "Fixture file has been formatted");
const path = context.filename;
const filename = path.slice(path.lastIndexOf(pathSep) + 1);
let debuggerCount = 0;
return {
Program(node) {
// Remove BOM in `bom_remove.js` and `bom_remove2.js`.
// ESLint's `unicode-bom` rule returns fixes with `range: [-1, 0]` to remove BOM.
// Check behavior is correct for both `fix` returning a single fix and returning multiple fixes.
if (filename === "bom_remove.js") {
context.report({
message: "Remove BOM",
node,
fix(fixer) {
return fixer.removeRange([-1, 0]);
},
});
} else if (filename === "bom_remove2.js") {
context.report({
message: "Remove BOM multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([-1, 0])];
},
});
}
},
DebuggerStatement(node) {
debuggerCount++;
let thisIsReport;
const report: Diagnostic = {
message: "Remove debugger statement",
node,
fix(fixer) {
thisIsReport = this === report;
if (debuggerCount === 1) return fixer.remove(node);
return fixer.removeRange([node.start, node.end]);
},
};
context.report(report);
if (!thisIsReport) {
context.report({ message: `this in fix function is not report object`, node });
}
},
Identifier(node) {
switch (node.name) {
case "a":
return context.report({
message: 'Replace "a" with "daddy"',
node,
fix(fixer) {
return fixer.replaceText(node, "daddy");
},
});
case "b":
return context.report({
message: 'Replace "b" with "abacus"',
node,
fix(fixer) {
return fixer.replaceTextRange([node.start, node.end], "abacus");
},
});
case "c":
return context.report({
message: 'Prefix "c" with "magi"',
node,
fix(fixer) {
return fixer.insertTextBefore(node, "magi");
},
});
case "d":
return context.report({
message: 'Prefix "d" with "damne"',
node,
fix(fixer) {
return fixer.insertTextBeforeRange([node.start, node.end], "damne");
},
});
case "e":
return context.report({
message: 'Postfix "e" with "lephant"',
node,
fix(fixer) {
return fixer.insertTextAfter(node, "lephant");
},
});
case "f":
return context.report({
message: 'Postfix "f" with "eck"',
node,
fix(fixer) {
return fixer.insertTextAfterRange([node.start, node.end], "eck");
},
});
case "g":
return context.report({
message: 'Replace "g" with "rage"',
node,
fix(fixer) {
// Add text before and after the node only, to test combining fixes that include original source.
// Fixes can be in any order.
return [
// Test that any object with `range` property works
fixer.insertTextAfter({ range: [node.start, node.end] }, "e"),
fixer.insertTextBefore(node, "ra"),
];
},
});
case "h":
return context.report({
message: 'Replace "h" with "dangermouse"',
node,
fix(fixer) {
// Fixes can be in any order
const { range } = node;
return [
fixer.replaceTextRange(range, "er"),
fixer.insertTextAfterRange(range, "mouse"),
fixer.insertTextBeforeRange(range, "dang"),
];
},
});
case "i":
return context.report({
message: 'Replace "i" with "granular"',
node,
// `fix` can be a generator function
*fix(fixer) {
yield fixer.insertTextBefore(node, "gra");
yield fixer.replaceText(node, "nu");
// Test that any object with `range` property works
yield fixer.insertTextAfter({ range: [node.start, node.end] } as Node, "lar");
},
});
case "j":
return context.report({
message: 'Replace "j" with "cowabunga"',
node,
// `fix` can be a generator function
*fix(fixer) {
// Fixes can be in any order
const { range } = node;
yield fixer.insertTextAfterRange(range, "bunga");
yield fixer.replaceTextRange(range, "a");
yield fixer.insertTextBeforeRange(range, "cow");
},
});
// Test behavior when `fix` returns a fix with illegal range.
// Check behavior is correct for both `fix` returning a single fix and returning multiple fixes.
case "x":
switch (filename) {
case "range_start_after_end.js":
context.report({
message: "start after end",
node,
fix(fixer) {
return fixer.removeRange([3, 2]);
},
});
context.report({
message: "start after end multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([3, 2])];
},
});
break;
case "range_end_out_of_bounds.js":
context.report({
message: "end out of bounds",
node,
fix(fixer) {
return fixer.removeRange([0, sourceText.length + 1]);
},
});
context.report({
message: "end out of bounds multiple",
node,
fix(fixer) {
return [
fixer.removeRange([0, 0]),
fixer.removeRange([0, sourceText.length + 1]),
];
},
});
break;
case "range_start_too_large.js":
context.report({
message: "start too large",
node,
fix(fixer) {
return fixer.removeRange([U32_MAX_PLUS_ONE, 0]);
},
});
context.report({
message: "start too large multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([U32_MAX_PLUS_ONE, 0])];
},
});
break;
case "range_end_too_large.js":
context.report({
message: "end too large",
node,
fix(fixer) {
return fixer.removeRange([0, U32_MAX_PLUS_ONE]);
},
});
context.report({
message: "end too large multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([0, U32_MAX_PLUS_ONE])];
},
});
break;
case "range_start_negative.js":
context.report({
message: "start negative",
node,
fix(fixer) {
return fixer.removeRange([-10, 0]);
},
});
context.report({
message: "start negative multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([-10, 0])];
},
});
break;
case "range_end_negative.js":
context.report({
message: "end negative",
node,
fix(fixer) {
return fixer.removeRange([0, -10]);
},
});
context.report({
message: "end negative multiple",
node,
fix(fixer) {
return [fixer.removeRange([0, 0]), fixer.removeRange([0, -10])];
},
});
break;
}
}
},
};
},
};
const plugin: Plugin = {
meta: {
name: "fixes-plugin",
},
rules: {
fixes: rule,
},
};
export default plugin;