Skip to content

Commit aaabde4

Browse files
committed
fix(parser): attach legal comments to following token (#21670)
## Summary - treat `Legal` and `JsdocLegal` comments as leading comments for the following token instead of allowing them to become trailing comments on the previous token - apply that rule to both legal block comments and legal line comments after code - add parser regressions for both cases and a codegen idempotency regression for comment-preserving minify mode ## Why `monitor-oxc` whitespace found a repeated print/parse instability in comment-preserving whitespace removal. Some bundled files contain legal comments such as `@license`/`@preserve` or `/*! ... */` between two statements. After one print, Oxc could produce a shape where a legal comment appeared after code and before the next token. On the second parse, the trivia builder sometimes classified that legal comment as trailing trivia for the previous token. That classification is wrong for legal-comment preservation. Oxc only treats legal comments as preservable legal comments when they are leading comments. So the parser should keep legal comments attachable to the following token instead of finalizing them as trailing comments. This makes repeated parse/print cycles stable in modes that remove whitespace but keep comments, without changing executable JavaScript semantics. Ordinary non-legal comments still use the existing leading/trailing classification. ## esbuild comparison I checked esbuild's implementation because Oxc's legal-comment behavior is intended to follow the same model. esbuild's lexer records legal comments in `LegalCommentsBeforeToken`, and the parser consumes that list before parsing the next statement by inserting `SComment { IsLegalComment: true }` statements. In other words, esbuild models legal comments as comments before the next token/statement, not as trailing trivia attached to the previous token. For the concrete repro, esbuild with `minify: true` and `legalComments: "inline"` keeps the legal comments inline and is idempotent across repeated transforms: ```js foo();/** * @license MIT **//*! #__NO_SIDE_EFFECTS__ */function bar(){} ``` The key invariant from esbuild is not the exact newline placement. It is that legal comments remain associated with the following statement/token for preservation. This change moves Oxc's parser classification toward that invariant while leaving ordinary non-legal trailing comments alone. ## Testing - `cargo test -p oxc_parser trivia_builder -- --nocapture` - `cargo test -p oxc_codegen comments -- --nocapture` - `cargo fmt --all --check` - `git diff --check` - `monitor-oxc whitespace` filtered to the two previously failing files (`@vue-macros/devtools` and `@asamuzakjp/css-color`): both files were collected and processed with no `RemoveWhitespace` diagnostics. The local runtime phase then failed separately under Node.js v25.8.1 on old package imports.
1 parent 8804425 commit aaabde4

3 files changed

Lines changed: 64 additions & 6 deletions

File tree

crates/oxc_codegen/tests/integration/comments.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::{
2-
test_idempotency,
2+
test_idempotency, test_idempotency_options,
33
tester::{test, test_same},
44
};
55

@@ -652,3 +652,20 @@ function foo() {
652652
fn test_pure_comment_on_object_idempotency() {
653653
test_idempotency("export const X = /* @__PURE__ */ { a: 1 };");
654654
}
655+
656+
#[test]
657+
fn test_legal_comment_after_code_minify_with_comments_idempotency() {
658+
use oxc_codegen::{CodegenOptions, CommentOptions};
659+
660+
test_idempotency_options(
661+
"foo();/**
662+
* @license MIT
663+
**//*! #__NO_SIDE_EFFECTS__ */function bar(){}",
664+
&CodegenOptions {
665+
minify: true,
666+
comments: CommentOptions::default(),
667+
source_map_path: Some("test.js".into()),
668+
..CodegenOptions::default()
669+
},
670+
);
671+
}

crates/oxc_codegen/tests/integration/tester.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,21 @@ pub fn codegen_options(source_text: &str, options: &CodegenOptions) -> CodegenRe
107107

108108
#[track_caller]
109109
pub fn test_idempotency(source_text: &str) {
110+
test_idempotency_options(source_text, &default_options());
111+
}
112+
113+
#[track_caller]
114+
pub fn test_idempotency_options(source_text: &str, options: &CodegenOptions) {
110115
let allocator = Allocator::default();
111116
let source_type = SourceType::tsx();
112117
let ret = Parser::new(&allocator, source_text, source_type).parse();
113118
assert!(ret.errors.is_empty(), "Parse errors: {:?}", ret.errors);
114-
let first = Codegen::new().with_options(default_options()).build(&ret.program).code;
119+
let first = Codegen::new().with_options(options.clone()).build(&ret.program).code;
115120

116121
let allocator2 = Allocator::default();
117122
let ret2 = Parser::new(&allocator2, &first, source_type).parse();
118123
assert!(ret2.errors.is_empty(), "Parse errors on second pass: {:?}", ret2.errors);
119-
let second = Codegen::new().with_options(default_options()).build(&ret2.program).code;
124+
let second = Codegen::new().with_options(options.clone()).build(&ret2.program).code;
120125

121126
assert_eq!(
122127
first, second,

crates/oxc_parser/src/lexer/trivia_builder.rs

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,9 @@ impl<'a> TriviaBuilder<'a> {
9595
// The last unprocessed comment is on a newline.
9696
let len = self.comments.len();
9797
if self.processed < len {
98-
self.comments[len - 1].set_followed_by_newline(true);
99-
if !self.saw_newline {
98+
let comment = &mut self.comments[len - 1];
99+
comment.set_followed_by_newline(true);
100+
if !self.saw_newline && !Self::should_stay_leading(comment) {
100101
self.processed = self.comments.len();
101102
}
102103
}
@@ -148,6 +149,11 @@ impl<'a> TriviaBuilder<'a> {
148149
!self.saw_newline && !matches!(self.previous_kind, Kind::Eq | Kind::LParen)
149150
}
150151

152+
fn should_stay_leading(comment: &Comment) -> bool {
153+
// Match esbuild's model where legal comments are preserved before the following token/statement.
154+
matches!(comment.content, CommentContent::Legal | CommentContent::JsdocLegal)
155+
}
156+
151157
/// Update `pure_comment` / `has_no_side_effects_comment` to point to the comment at `index`.
152158
fn set_annotation_flags(&mut self, comment: &Comment, index: usize) {
153159
if comment.is_pure() {
@@ -177,7 +183,8 @@ impl<'a> TriviaBuilder<'a> {
177183
if comment.is_line() {
178184
// A line comment is always followed by a newline. This is never set in `handle_newline`.
179185
comment.set_followed_by_newline(true);
180-
if self.should_be_treated_as_trailing_comment() {
186+
if self.should_be_treated_as_trailing_comment() && !Self::should_stay_leading(&comment)
187+
{
181188
self.processed = self.comments.len() + 1; // +1 to include this comment.
182189
}
183190
self.saw_newline = true;
@@ -502,6 +509,35 @@ token /* Trailing 1 */
502509
assert_eq!(comments, expected);
503510
}
504511

512+
#[test]
513+
fn legal_comment_after_code_is_attached_to_next_token() {
514+
let source_text = "foo();/**
515+
* @license MIT
516+
**/
517+
function bar() {}";
518+
let comments = get_comments(source_text);
519+
let function_start = u32::try_from(source_text.find("function").unwrap()).unwrap();
520+
521+
assert_eq!(comments.len(), 1);
522+
assert_eq!(comments[0].position, CommentPosition::Leading);
523+
assert_eq!(comments[0].attached_to, function_start);
524+
assert!(comments[0].is_legal());
525+
assert!(comments[0].followed_by_newline());
526+
}
527+
528+
#[test]
529+
fn legal_line_comment_after_code_is_attached_to_next_token() {
530+
let source_text = "foo();//! @license MIT\nfunction bar() {}";
531+
let comments = get_comments(source_text);
532+
let function_start = u32::try_from(source_text.find("function").unwrap()).unwrap();
533+
534+
assert_eq!(comments.len(), 1);
535+
assert_eq!(comments[0].position, CommentPosition::Leading);
536+
assert_eq!(comments[0].attached_to, function_start);
537+
assert!(comments[0].is_legal());
538+
assert!(comments[0].followed_by_newline());
539+
}
540+
505541
#[test]
506542
fn leading_comments_after_eq() {
507543
let source_text = "

0 commit comments

Comments
 (0)