Files
camc314 3fd78d7451 fix(codegen): preserve names in duplicate source mappings (#26051)
When an unnamed parent node and a mangled identifier begin at the same source position, codegen deduplicated the identifier mapping. This retained location data but discarded the original identifier name, leaving consumers unable to restore inferred function names in source-mapped Node stack traces.

```mermaid
flowchart LR
    A[AssignmentExpression mapping<br/>unnamed] --> B[Token at source position P<br/>name: none]
    C[Identifier mapping<br/>name: longName] --> D{Same source position P?}
    D -->|before| E[Drop duplicate<br/>names: empty]
    D -->|after| F[Enrich existing token<br/>name: longName]
```

### Changes

- Cache whether the last emitted token has a name, keeping duplicate-position handling cheap in the source-map builder hot path.
- Enrich an unnamed duplicate token when a later identifier mapping supplies the original name.
- Add a unit regression test for the builder behavior.
- Extend the existing Node source-map stack-trace snapshot test with a mangled ES module fixture.

The Node fixture verifies:

```text
function longFunctionName(longName) { ... }
            ↓ mangled
function e(e) { ... }
```

and asserts that the source map includes both original names while Node restores the inferred inner frame as:

```text
at longName (/project/input.js:6:15)
```
2026-11-03 02:22:04 +00:00

602 lines
20 KiB
Rust

use std::{env, path::PathBuf};
use cow_utils::CowUtils;
use oxc_allocator::Allocator;
use oxc_ast::ast::{Expression, Statement};
use oxc_codegen::{Codegen, CodegenOptions};
use oxc_index::IndexVec;
use oxc_mangler::{MangleOptions, Mangler};
use oxc_parser::Parser;
use oxc_span::{SourceType, Span};
use oxc_str::CompactStr;
use rustc_hash::FxHashMap;
use crate::tester::default_options;
#[derive(Clone, Copy)]
struct Position {
line: u32,
col: u32,
}
#[derive(Clone, Copy)]
struct Mapping {
dst: Position,
src: Position,
}
fn pos(line: u32, col: u32) -> Position {
Position { line, col }
}
fn sourcemap_tokens(source_text: &str, source_type: SourceType) -> Vec<Mapping> {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty(), "parse errors: {:?}", ret.diagnostics);
Codegen::new()
.with_options(default_options())
.build(&ret.program)
.map
.expect("sourcemap should be generated")
.get_tokens()
.map(|token| Mapping {
dst: pos(token.get_dst_line(), token.get_dst_col()),
src: pos(token.get_src_line(), token.get_src_col()),
})
.collect()
}
fn has_mapping(tokens: &[Mapping], src: Position, dst: Position) -> bool {
tokens.iter().any(|token| {
token.src.line == src.line
&& token.src.col == src.col
&& token.dst.line == dst.line
&& token.dst.col == dst.col
})
}
fn first_generated_position_for_source(tokens: &[Mapping], src: Position) -> Option<Position> {
tokens
.iter()
.filter(|token| token.src.line == src.line && token.src.col == src.col)
.map(|token| token.dst)
.min_by_key(|position| (position.line, position.col))
}
fn assert_source_maps_after_indent(
tokens: &[Mapping],
src: Position,
wrong_dst: Position,
correct_dst: Position,
) {
assert!(!has_mapping(tokens, src, wrong_dst));
assert!(has_mapping(tokens, src, correct_dst));
}
fn assert_member_start_maps_before_key(
tokens: &[Mapping],
member_start: Position,
key_start: Position,
) {
let member_start = first_generated_position_for_source(tokens, member_start)
.expect("member start should be mapped");
let key_start = first_generated_position_for_source(tokens, key_start)
.expect("member key should be mapped");
assert_eq!(member_start.line, key_start.line);
assert!(member_start.col < key_start.col);
}
/// Upstream may have modified the AST to include incorrect spans.
/// e.g. <https://github.com/rolldown/rolldown/blob/v1.0.0-beta.19/crates/rolldown/src/utils/ecma_visitors/mod.rs>
#[test]
fn incorrect_ast() {
let allocator = Allocator::default();
let source_type = SourceType::ts();
let source_text = "foo\nvar bar = '测试'";
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty());
let mut program = ret.program;
program.span = Span::new(0, 0);
if let Statement::ExpressionStatement(s) = &mut program.body[0] {
s.span = Span::new(17, 17);
if let Expression::Identifier(ident) = &mut s.expression {
ident.span = Span::new(17, 17);
}
}
let ret = Codegen::new().with_options(default_options()).build(&program);
assert!(ret.map.is_some(), "sourcemap exists");
}
/// Test that sourcemaps don't contain invalid tokens for positions beyond source content.
/// This addresses the issue where oxc_codegen adds semicolons/newlines and creates tokens
/// for positions that don't exist in the original source.
/// See: https://github.com/rolldown/rolldown/pull/6750
#[test]
fn no_invalid_tokens_beyond_source() {
let test_cases = vec![
// Export statement without trailing semicolon
"export default { foo }",
// Variable declaration without trailing semicolon
"const a = 1",
// Function without trailing semicolon
"function foo() { return 42 }",
// Object with shorthand property
"const obj = { foo }",
];
for source_text in test_cases {
let allocator = Allocator::default();
let source_type = SourceType::mjs();
let ret = Parser::new(&allocator, source_text, source_type).parse();
assert!(ret.diagnostics.is_empty());
let result = Codegen::new()
.with_options(CodegenOptions {
source_map_path: Some(PathBuf::from("test.js")),
..Default::default()
})
.build(&ret.program);
let map = result.map.unwrap();
// Verify all tokens have source positions within bounds
for token in map.get_tokens() {
if let Some(source_id) = token.get_source_id()
&& let Some(content) = map.get_source_content(source_id)
{
let src_line = token.get_src_line() as usize;
let src_col = token.get_src_col() as usize;
let lines: Vec<&str> = content.split('\n').collect();
assert!(
src_line < lines.len(),
"Invalid token: line {src_line} is beyond source line count {} for source '{source_text}'",
lines.len(),
);
let line_content = lines[src_line];
let line_len_utf16: usize = line_content.chars().map(char::len_utf16).sum();
assert!(
src_col < line_len_utf16,
"Invalid token: column {src_col} is beyond line length {line_len_utf16} for line '{line_content}' in source '{source_text}'",
);
}
}
}
}
#[test]
fn indented_statement_mappings_start_after_generated_indent() {
let tokens = sourcemap_tokens(
r"if (foo) {
bar();
}",
SourceType::mjs(),
);
assert_source_maps_after_indent(&tokens, pos(1, 2), pos(1, 0), pos(1, 1));
}
// `Directive`, `ImportDeclaration`, `ExportNamedDeclaration`,
// `ExportAllDeclaration`, and `ExportDefaultDeclaration` previously called
// `add_source_mapping` *before* `print_indent`, anchoring the mapping at
// gen col 0 (whitespace) instead of the start of the keyword.
#[test]
fn top_level_decl_mappings_start_after_generated_indent() {
// Wrap the imports/exports in an ambient module so the body is
// indented, exposing the order of `add_source_mapping` vs `print_indent`.
let tokens = sourcemap_tokens(
r#"declare module "m" {
"use strict";
import { x } from "x";
export { x } from "x";
export * from "x";
export default 1;
}"#,
SourceType::ts(),
);
// Directive `"use strict"` source col 0 of line 1 → gen col 1 (after tab),
// not gen col 0.
assert_source_maps_after_indent(&tokens, pos(1, 0), pos(1, 0), pos(1, 1));
// ImportDeclaration
assert_source_maps_after_indent(&tokens, pos(2, 0), pos(2, 0), pos(2, 1));
// ExportNamedDeclaration
assert_source_maps_after_indent(&tokens, pos(3, 0), pos(3, 0), pos(3, 1));
// ExportAllDeclaration
assert_source_maps_after_indent(&tokens, pos(4, 0), pos(4, 0), pos(4, 1));
// ExportDefaultDeclaration
assert_source_maps_after_indent(&tokens, pos(5, 0), pos(5, 0), pos(5, 1));
}
#[test]
fn class_member_mappings_start_before_member_keys() {
let tokens = sourcemap_tokens(
r"class Foo {
get value() {
return 1;
}
static load() {
return 1;
}
}",
SourceType::ts(),
);
assert_member_start_maps_before_key(&tokens, pos(1, 2), pos(1, 6));
assert_member_start_maps_before_key(&tokens, pos(5, 2), pos(5, 9));
}
// `print_block_end` wraps a non-block consequent when codegen must preserve
// `else` binding. For parser-produced source this shape is not naturally
// reachable - an outer `else` either binds to the nearest inner `if`, or the
// source uses an explicit block and goes through `print_block_statement`.
// Consumers can still hit this path when printing transformed or hand-built ASTs.
#[test]
fn synthesized_block_closing_braces_are_mapped() {
let source_text = "if (foo) {\n if (bar)\n baz();\n} else\n qux();";
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty(), "parse errors: {:?}", ret.diagnostics);
let mut program = ret.program;
let Statement::IfStatement(outer_if) = &mut program.body[0] else {
panic!("expected outer if statement");
};
let consequent = {
let Statement::BlockStatement(block) = &mut outer_if.consequent else {
panic!("expected block statement");
};
assert_eq!(block.body.len(), 1);
block.body.remove(0)
};
outer_if.consequent = consequent;
let ret = Codegen::new().with_options(default_options()).build(&program);
assert_eq!(ret.code, "if (foo) {\n\tif (bar) baz();\n} else qux();\n");
let tokens: Vec<Mapping> = ret
.map
.expect("sourcemap should be generated")
.get_tokens()
.map(|token| Mapping {
dst: pos(token.get_dst_line(), token.get_dst_col()),
src: pos(token.get_src_line(), token.get_src_col()),
})
.collect();
// The emitted `}` after `if (bar) baz();` maps back to the end of `baz();`.
assert!(
has_mapping(&tokens, pos(2, 9), pos(2, 0)),
"expected the synthesized block closing brace to map back to the wrapped if statement",
);
}
// Both `)` of `factory()()` should map to their own source position
// at the gen position of the `)`, not one past it.
#[test]
fn call_end_mapping_lands_at_close_paren() {
let tokens = sourcemap_tokens("factory()()", SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, 8), pos(0, 8)), "inner `)`");
assert!(has_mapping(&tokens, pos(0, 10), pos(0, 10)), "outer `)`");
}
// `print_block_end`: source `}` → gen `}`, not the `;` that follows.
#[test]
fn block_end_mapping_lands_at_close_brace() {
let source = "const fn = () => { return 1 }";
let src_brace = u32::try_from(source.rfind('}').unwrap()).unwrap();
let tokens = sourcemap_tokens(source, SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, src_brace), pos(2, 0)));
}
// `print_curly_braces` (shared by class body, switch, TS enum/interface/
// typeliteral/module): pin one to cover the shared helper.
#[test]
fn class_body_close_brace_lands_at_close_brace() {
let source = "class C { a; }";
let src_brace = u32::try_from(source.rfind('}').unwrap()).unwrap();
let tokens = sourcemap_tokens(source, SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, src_brace), pos(2, 0)));
}
#[test]
fn array_close_bracket_lands_at_close_bracket() {
let source = "const a = [1, 2]";
let src_bracket = u32::try_from(source.rfind(']').unwrap()).unwrap();
let tokens = sourcemap_tokens(source, SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, src_bracket), pos(0, src_bracket)));
}
#[test]
fn object_close_brace_lands_at_close_brace() {
let source = "const o = { a: 1 }";
let src_brace = u32::try_from(source.rfind('}').unwrap()).unwrap();
let tokens = sourcemap_tokens(source, SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, src_brace), pos(0, src_brace)));
}
// When a callee/object ends in `)` or `]` it has no trailing identifier for V8
// to anchor a stack frame on, so the chain punctuation (`(`/`.`) right after it
// must be mapped — otherwise the column resolves one too far left (off-by-one
// stack traces reported by vitest via vite-ecosystem-ci).
// Curried call: V8 reports the outer call at the `(` after the inner `)`.
#[test]
fn nested_call_outer_open_paren_resolves_to_source() {
let tokens = sourcemap_tokens("factory()()", SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, 9), pos(0, 9)), "outer `(` of `factory()()`");
}
// Member/getter access on a call result: reported at the `.` after the `)`.
#[test]
fn member_access_after_call_resolves_to_source() {
let tokens = sourcemap_tokens("expect(x).resolves", SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, 9), pos(0, 9)), "`.` after `expect(x)`");
}
// Same gap for a computed-member result: reported at the `(` after the `]`.
#[test]
fn call_after_computed_member_resolves_to_source() {
let tokens = sourcemap_tokens("arr[i]()", SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, 6), pos(0, 6)), "`(` after `arr[i]`");
}
// A tagged template whose tag is a call result (`` f()`x` ``) is the same shape
// — the tag is a postfix operand, so the backtick after the `)` is mapped too.
#[test]
fn tagged_template_after_call_resolves_to_source() {
let tokens = sourcemap_tokens("f()`x`", SourceType::mjs());
assert!(has_mapping(&tokens, pos(0, 3), pos(0, 3)), "backtick after `f()`");
}
#[test]
#[cfg(all(not(target_endian = "big"), target_pointer_width = "64"))] // we run big endian tests on docker that does not have node installed; skip 32-bit as well
fn stacktrace_is_correct() {
let cases = &[
"\
const fn = () => {
Error.stackTraceLimit = 2;
throw new Error()
};
fn()",
"\
const obj = {
fn() {
Error.stackTraceLimit = 2;
throw new Error()
}
}
obj.fn()",
"\
const obj = {
obj2: {
fn() {
Error.stackTraceLimit = 2;
throw new Error()
}
}
}
obj.obj2.fn()",
"\
const obj = {
fn() {
return function fn2() {
Error.stackTraceLimit = 2;
throw new Error()
}
}
}
obj.fn()()",
"\
const obj = {
fn() {
return () => {
Error.stackTraceLimit = 2;
throw new Error()
}
}
}
obj.fn([1])()",
"\
const factory = () => {
return () => {
Error.stackTraceLimit = 2;
throw new Error()
}
}
factory()()",
"\
var a
const obj = {
fn() {
return () => {
Error.stackTraceLimit = 2;
throw new Error()
}
}
}
obj.fn({a})()",
"\
const fn = (name, cb) => {
cb()
}
fn('name', () => {
Error.stackTraceLimit = 2;
throw new Error()
})",
// Getter accessed on a call result (`expect(x).resolves` shape): V8
// reports the access at the `.` right after the call's `)`.
"\
const make = () => ({
get prop() {
Error.stackTraceLimit = 2;
throw new Error()
}
})
make().prop",
];
// The identifier is first mapped by its enclosing AssignmentExpression. The map must retain
// the later identifier mapping's original name so Node can restore the inferred function name.
// `export {}` makes this a module, allowing the enclosing function to be mangled as well.
let mangled_case = r"
export {};
function longFunctionName(longName) {
(longName = () => {
Error.stackTraceLimit = 2;
throw new Error();
})();
}
longFunctionName(0);
";
let (mangled_output, mangled_sourcemap_url, mangled_names) = codegen_mangled(mangled_case);
assert_eq!(mangled_names, ["longFunctionName", "longName"]);
let node_version = std::process::Command::new("node").arg("--version").output().map_or_else(
|_| "unknown".to_string(),
|output| String::from_utf8_lossy(&output.stdout).trim().to_string(),
);
insta::with_settings!({ prepend_module_to_snapshot => false, snapshot_suffix => "", omit_expression => true }, {
insta::assert_snapshot!(
"stacktrace_is_correct",
format!(
"Node.js version: {}\n\n{}",
node_version,
cases.iter().map(|s| {
let (output, sourcemap_url) = codegen(s);
format!("## Input\n{}\n\n## Output\n{}\n\n## Stderr\n{}", s, output, execute_with_node(&output, &sourcemap_url))
}).chain(std::iter::once(format!(
"## Mangled identifier source-map names\n{:?}\n\n## Input\n{}\n\n## Output\n{}\n\n## Stderr\n{}",
mangled_names,
mangled_case.trim(),
mangled_output,
execute_with_node(&mangled_output, &mangled_sourcemap_url),
))).collect::<Vec<_>>().join("\n------------------------------------------------------\n")
)
);
});
}
fn codegen(code: &str) -> (String, String) {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, code, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty());
let ret = Codegen::new()
.with_options(CodegenOptions {
source_map_path: Some(PathBuf::from("input.js")),
..Default::default()
})
.build(&ret.program);
(ret.code, ret.map.unwrap().to_data_url())
}
/// Print `source_text` with every private member of the first class renamed as `mappings` says,
/// and return the names its source map records.
fn mangled_private_names(source_text: &str, mappings: &[(&str, &str)]) -> Vec<String> {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source_text, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty(), "parse errors: {:?}", ret.diagnostics);
let mut class_mappings = IndexVec::new();
class_mappings.push(
mappings
.iter()
.map(|(from, to)| ((*from).to_string(), CompactStr::from(*to)))
.collect::<FxHashMap<_, _>>(),
);
Codegen::new()
.with_options(default_options())
.with_private_member_mappings(Some(class_mappings))
.build(&ret.program)
.map
.expect("sourcemap should be generated")
.get_names()
.map(ToString::to_string)
.collect()
}
#[test]
fn private_identifier_is_named_only_when_mangled() {
let source_text = "class C { #ab; m() { return this.#ab; } }";
// Printed as spelled, so nothing to name
assert_eq!(mangled_private_names(source_text, &[]), Vec::<String>::new());
// The token is `#ab`, so that is the name recorded - not `ab`, which would describe a region
// of the output which includes the `#`
assert_eq!(mangled_private_names(source_text, &[("ab", "a")]), vec!["#ab".to_string()]);
}
fn codegen_mangled(code: &str) -> (String, String, Vec<String>) {
let allocator = Allocator::default();
let ret = Parser::new(&allocator, code, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty());
let program = ret.program;
let mangler = Mangler::new()
.with_options(MangleOptions { top_level: Some(true), ..MangleOptions::default() })
.build(&program);
let ret = Codegen::new()
.with_options(CodegenOptions {
source_map_path: Some(PathBuf::from("input.js")),
..Default::default()
})
.with_scoping(Some(mangler.scoping))
.with_private_member_mappings(Some(mangler.class_private_mappings))
.build(&program);
let map = ret.map.unwrap();
let names = map.get_names().map(str::to_owned).collect();
(ret.code, map.to_data_url(), names)
}
fn execute_with_node(code: &str, sourcemap_url: &str) -> String {
let cwd = env::current_dir().unwrap().join("input.js");
let cwd = cwd.to_str().unwrap();
let code = format!("{code}\n//# sourceMappingURL={sourcemap_url}\n");
let output = std::process::Command::new("node")
.arg("--enable-source-maps")
.args(["--input-type", "module"])
.args(["--eval", &code])
.output()
.unwrap();
String::from_utf8_lossy(&output.stderr)
.cow_replace(cwd, "/project/input.js")
.lines()
.filter(|line| !line.starts_with("Node.js v"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn minified_template_dollar_escapes() {
let source = r"`\$${first}\$${second}\$`;after();";
let allocator = Allocator::default();
let ret = Parser::new(&allocator, source, SourceType::mjs()).parse();
assert!(ret.diagnostics.is_empty());
let result = Codegen::new()
.with_options(CodegenOptions { minify: true, ..default_options() })
.build(&ret.program);
assert_eq!(result.code, r"`$${first}$${second}$`;after();");
let map = result.map.unwrap();
for name in ["first", "second", "after"] {
assert!(
map.get_tokens().any(|token| {
token.get_src_line() == 0
&& token.get_dst_line() == 0
&& token.get_src_col() == u32::try_from(source.find(name).unwrap()).unwrap()
&& token.get_dst_col()
== u32::try_from(result.code.find(name).unwrap()).unwrap()
}),
"missing mapping for {name}"
);
}
}