Files
Boshen 777f02ae10 refactor(diagnostics)!: return a Diagnostics type instead of Vec<OxcDiagnostic> (#23217)
## Summary

Introduces `oxc_diagnostics::Diagnostics` — a `Vec<OxcDiagnostic>` newtype that knows the difference between errors and warnings — and makes it the standard diagnostics return value across oxc.

- **New type**: `Diagnostics` with `has_errors()`, `has_warnings()`, `errors()`, `warnings()`, plus `Vec`-like ergonomics (Deref, `IntoIterator`, `Extend`, `From`/`Into<Vec>`) so most call sites are unchanged.
- **Return types**: `ParserReturn`, `SemanticBuilderReturn`, `IsolatedDeclarationsReturn`, the React Compiler `TransformResult`/`LintResult`, and `TransformerReturn` now expose `diagnostics: Diagnostics` instead of `errors: Vec<OxcDiagnostic>` (the React Compiler's separate `errors`/`warnings` are merged into one severity-tagged list).
- **Behaviour**: a React Compiler **error** now stops the transform (no output), while **warnings** flow through and the transform continues. `compile()` aborts codegen only when `diagnostics.has_errors()` — warnings alone no longer abort (previously any diagnostic did).

## Breaking change

Consumers reading `*.errors` on these return values must read `*.diagnostics`. React Compiler consumers that read `.errors`/`.warnings` separately should use `diagnostics.has_errors()` / `diagnostics.errors()` / `diagnostics.warnings()`.

Parser/semantic/isolated-declarations fatality is otherwise unchanged. Builds on the React Compiler transform feature (#23201).

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-06-10 14:46:52 +00:00

492 lines
16 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_parser::Parser;
use oxc_span::{SourceType, Span};
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 `if (true) { ... }` so the body is
// indented, exposing the order of `add_source_mapping` vs `print_indent`.
let tokens = sourcemap_tokens(
r#"if (true) {
"use strict";
import { x } from "x";
export { x } from "x";
export * from "x";
export default 1;
}"#,
SourceType::mjs(),
);
// 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",
];
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))
}).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())
}
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")
}