fix(ast_visit): add the trimmed prefix length to translation table offsets (#26173)

Closes #26162.

`Utf8ToUtf16::new_with_offset` seeds the translation table with an
absolute BOM entry, `{ utf8_offset: 3, utf16_difference: 3 }`, but
`build_translations` records later entries relative to the trimmed
source text. From the first non-ASCII character on, the table is 3 bytes
short and offsets land on the wrong side of a boundary.

For the issue repro, a JS plugin reading
`context.sourceCode.getAllComments()[0]` gets `0..4`, so
`text.slice(start, end)` is `"/*x*"` instead of `"/*x*/"`. Parsing the
BOM'd file and converting comment spans the way `parse.rs` does gives
`comment span = 0..4`, `"/*x*"` before the change and `comment span =
0..5`, `"/*x*/"` after.

Add `offset` back on when pushing the entry so the table stays absolute.
This is one line in `build_translations`.

`Utf8ToUtf16::new` passes `offset: 0`, so the parser, coverage, and
benchmarks are unchanged. The four non-zero-offset callers are in the
linter's JS-plugin path: `crates/oxc_linter/src/lib.rs:752` (BOM) and
`:754` (leading newline of a Vue/Astro/Svelte fragment),
`apps/oxlint/src/js_plugins/parse.rs:249` and `fix.rs:34`. Each trims
the prefix from `source_text` before constructing this type. I left the
other two spots the reporter flagged alone because they look like
separate bugs.

Both new tests fail on `db66f58` before the change and pass after:

- `translate_offsets_after_trimmed_bom`: comment end `8` converts to
`4`, expected `5`.
- `translate_offsets_with_trimmed_prefix`: sweeps every byte offset of
three sources against the same source with nothing trimmed, for a 1-byte
and a 3-byte prefix. One source puts the non-ASCII characters in the
SIMD aligned-chunk path rather than the short-source path.

Ran on `db66f58`, macOS arm64, rustc 1.98.0:

- `cargo test -p oxc_ast_visit --lib --features serialize`: 5 passed, 0
failed (3 passed, 2 failed without the fix)
- `cargo test -p oxc_linter`: 1256 passed, 0 failed
- `cargo clippy -p oxc_ast_visit --all-features --all-targets -- -D
warnings`: clean
- `cargo fmt --all --check`: clean

Per the AI usage policy: I used an AI assistant on this. The diagnosis,
the tests and the numbers above are all mine and I ran every command
quoted here.
This commit is contained in:
Bharadwaj Pendyala authored and GitHub committed 2026-09-03 13:55:02 +01:00
1 parent 6365db964b
commit f07dca52c4
2 files changed
+61 -2

No files matched your search

@@ -157,6 +157,8 @@ mod test {
use super::Utf8ToUtf16;
const BOM_LEN: u32 = 3;
#[test]
fn translate_ast() {
let allocator = Allocator::new();
@@ -205,6 +207,61 @@ mod test {
assert_eq!(convert_back(11), 15);
}
#[test]
fn translate_offsets_after_trimmed_bom() {
// Source text has the BOM trimmed off the start, but spans are still relative to the file
// including the BOM, so the offsets in the table are too.
let table = Utf8ToUtf16::new_with_offset("/*x*/'é'", BOM_LEN);
let mut converter = table.converter().unwrap();
// (UTF-8 offset including BOM, UTF-16 offset excluding BOM)
let offsets = [(3u32, 0u32), (8, 5), (9, 6), (11, 7), (12, 8)];
for (utf8_offset, expected_utf16_offset) in offsets {
let mut offset = utf8_offset;
converter.convert_offset(&mut offset);
assert_eq!(offset, expected_utf16_offset);
}
for (expected_utf8_offset, utf16_offset) in offsets {
let mut offset = utf16_offset;
converter.convert_offset_back(&mut offset);
assert_eq!(offset, expected_utf8_offset);
}
}
#[test]
fn translate_offsets_with_trimmed_prefix() {
// Linter trims a BOM, or the leading newline of a Vue/Astro/Svelte fragment, off the source
// text before building the table. Neither is part of the UTF-16 offsets, so an offset in
// the trimmed source has to convert the same way it would with nothing trimmed.
let sources = [
"/*x*/'é'",
"/*x*/'🤨'",
// Long enough that the non-ASCII characters land in the aligned-chunk path
"/*xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx*/'£ऊ🤨'",
];
for source_text in sources {
for trimmed_len in [1u32, BOM_LEN] {
let trimmed_table = Utf8ToUtf16::new_with_offset(source_text, trimmed_len);
let mut trimmed = trimmed_table.converter().unwrap();
let whole_table = Utf8ToUtf16::new(source_text);
let mut whole = whole_table.converter().unwrap();
#[expect(clippy::cast_possible_truncation)]
for utf8_offset in 0..=(source_text.len() as u32) {
let mut expected = utf8_offset;
whole.convert_offset(&mut expected);
let mut actual = utf8_offset + trimmed_len;
trimmed.convert_offset(&mut actual);
assert_eq!(actual, expected, "{source_text:?} at offset {utf8_offset}");
}
}
}
}
#[test]
fn translate_offsets() {
assert_eq!('_'.len_utf8(), 1);
@@ -90,11 +90,13 @@ pub fn build_translations(source_text: &str, translations: &mut Vec<Translation>
// Record the index of the end of this Unicode character, because it's only offsets
// *after* this Unicode character that need to be shifted.
// Addition cannot overflow because length of source text is max `u32::MAX`.
// Offsets are relative to the start of the untrimmed source, so `offset` is added
// back on. Addition cannot overflow because the untrimmed source text is max
// `u32::MAX` bytes long.
let bytes_in_char =
difference_for_this_byte as usize + usize::from(byte >= 0xF0) + 1;
#[expect(clippy::cast_possible_truncation)]
let utf8_offset = (start_offset + index + bytes_in_char) as u32;
let utf8_offset = (start_offset + index + bytes_in_char) as u32 + offset;
translations.push(Translation { utf8_offset, utf16_difference });
}
}