feat(parser): distinguish unapplied no-side-effects comments (#26120)

## Summary 

Fixes #26088

`NO_SIDE_EFFECTS` can either be applied to a function:

```rs
/* #__NO_SIDE_EFFECTS__ */ function foo() {}
```

Or be meaningless for its position, since it only works on function
declaration:
```rs
/* #__NO_SIDE_EFFECTS__ */ value;
```

But oxc could not distinguish these two cases. 

This PR adds `CommentContent::NoSideEffectsNotApplied`, mirroring the
existing `PureNotApplied`.

---------

Co-authored-by: Cameron <cameron.clark@hey.com>
This commit is contained in:
碳苯 CarbonandCameron authored and GitHub committed 2026-09-03 11:14:24 +01:00
1 parent 5f9cffc753
commit bfb4c57a5d
8 files changed
+161 -61

No files matched your search

+9 -6
View File
@@ -78,37 +78,40 @@ pub enum CommentContent {
/// `/* #__NO_SIDE_EFFECTS__ */`
NoSideEffects = 6,
/// `/* #__NO_SIDE_EFFECTS__ */` that could not be applied to a function
NoSideEffectsNotApplied = 7,
/// Webpack magic comment
/// e.g. `/* webpackChunkName */`
/// <https://webpack.js.org/api/module-methods/#magic-comments>
Webpack = 7,
Webpack = 8,
/// Vite comment
/// e.g. `/* @vite-ignore */`
/// <https://github.com/search?q=repo%3Avitejs%2Fvite%20vite-ignore&type=code>
Vite = 8,
Vite = 9,
/// Code Coverage Ignore
/// `v8 ignore`, `c8 ignore`, `node:coverage`, `istanbul ignore`
/// <https://github.com/oxc-project/oxc/issues/10091>
CoverageIgnore = 9,
CoverageIgnore = 10,
/// Turbopack magic comment
/// e.g. `/* turbopackOptional: true */`
/// <https://nextjs.org/docs/app/guides/lazy-loading#turbopackoptional-turbopack-only>
Turbopack = 10,
Turbopack = 11,
/// File-level code coverage ignore.
///
/// `v8 ignore file`, `istanbul ignore file`.
/// Classified separately because its meaning remains valid if the next AST
/// node is removed, unlike position-sensitive coverage annotations.
CoverageIgnoreFile = 11,
CoverageIgnoreFile = 12,
/// Marks the following string or no-substitution template as a property name.
/// `/* @__KEY__ */` or `/* #__KEY__ */`
/// <https://esbuild.github.io/api/#mangle-key>
PropertyKey = 12,
PropertyKey = 13,
}
bitflags! {
+4 -5
View File
@@ -508,13 +508,12 @@ fn pure_comment() {
test_same(
"/* #__PURE__ -- @preserve */ pureOperation();\n", // rolldown#9408
);
// A `@__NO_SIDE_EFFECTS__` comment sharing the call site's `attached_to`
// must not be emitted in place of the pure-call annotation. Without the
// kind filter, `FxHashMap` last-write-wins would print the wrong
// annotation kind in front of a CallExpression.
// A misplaced `@__NO_SIDE_EFFECTS__` comment sharing the call site's `attached_to`
// must be preserved for downstream consumers to warn about, without replacing the
// valid pure-call annotation.
test(
"/* @__PURE__ */ /* @__NO_SIDE_EFFECTS__ */ pureOperation();\n",
"/* @__PURE__ */ pureOperation();\n",
"/* @__NO_SIDE_EFFECTS__ */ /* @__PURE__ */ pureOperation();\n",
);
test("const foo /* #__PURE__ */ = pureOperation();", "const foo = pureOperation();\n"); // INVALID: "=" not allowed after annotation
@@ -155,11 +155,11 @@ export default function() {}
/* #__NO_SIDE_EFFECTS__ */ export const c2 = () => {}, c3 = () => {}
----------
export var v0 = function() {}, v1 = function() {};
export let l0 = function() {}, l1 = function() {};
/* #__NO_SIDE_EFFECTS__ */ export var v0 = function() {}, v1 = function() {};
/* #__NO_SIDE_EFFECTS__ */ export let l0 = function() {}, l1 = function() {};
export const c0 = /* @__NO_SIDE_EFFECTS__ */ function() {}, c1 = function() {};
export var v2 = () => {}, v3 = () => {};
export let l2 = () => {}, l3 = () => {};
/* #__NO_SIDE_EFFECTS__ */ export var v2 = () => {}, v3 = () => {};
/* #__NO_SIDE_EFFECTS__ */ export let l2 = () => {}, l3 = () => {};
export const c2 = /* @__NO_SIDE_EFFECTS__ */ () => {}, c3 = () => {};
########## 8
@@ -172,11 +172,11 @@ export const c2 = /* @__NO_SIDE_EFFECTS__ */ () => {}, c3 = () => {};
/* #__NO_SIDE_EFFECTS__ */ const c2 = () => {}, c3 = () => {}
----------
var v0 = function() {}, v1 = function() {};
let l0 = function() {}, l1 = function() {};
/* #__NO_SIDE_EFFECTS__ */ var v0 = function() {}, v1 = function() {};
/* #__NO_SIDE_EFFECTS__ */ let l0 = function() {}, l1 = function() {};
const c0 = /* @__NO_SIDE_EFFECTS__ */ function() {}, c1 = function() {};
var v2 = () => {}, v3 = () => {};
let l2 = () => {}, l3 = () => {};
/* #__NO_SIDE_EFFECTS__ */ var v2 = () => {}, v3 = () => {};
/* #__NO_SIDE_EFFECTS__ */ let l2 = () => {}, l3 = () => {};
const c2 = /* @__NO_SIDE_EFFECTS__ */ () => {}, c3 = () => {};
########## 9
+22 -13
View File
@@ -272,8 +272,8 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let start = self.cur_start();
let opening_span = self.cur_token().span();
// Capture annotation flags before bumping `(` since bump resets them
let has_no_side_effects_comment =
self.lexer.trivia_builder.previous_token_has_no_side_effects_comment();
let no_side_effects_comments =
self.lexer.trivia_builder.previous_token_no_side_effects_comments();
self.bump_any(); // `bump` `(`
let expr_start = self.cur_start();
let (mut expressions, comma_start) = self.context(Context::In, Context::Decorator, |p| {
@@ -312,14 +312,16 @@ impl<'a, C: Config> ParserImpl<'a, C> {
match &mut expression {
Expression::ArrowFunctionExpression(arrow_expr) => {
arrow_expr.pife = true;
if has_no_side_effects_comment {
if let Some(comments) = no_side_effects_comments {
arrow_expr.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
}
Expression::FunctionExpression(func_expr) => {
func_expr.pife = true;
if has_no_side_effects_comment {
if let Some(comments) = no_side_effects_comments {
func_expr.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
}
_ => {}
@@ -1489,8 +1491,8 @@ impl<'a, C: Config> ParserImpl<'a, C> {
&mut self,
allow_return_type_in_arrow_function: bool,
) -> Expression<'a> {
let has_no_side_effects_comment =
self.lexer.trivia_builder.previous_token_has_no_side_effects_comment();
let no_side_effects_comments =
self.lexer.trivia_builder.previous_token_no_side_effects_comments();
// [+Yield] YieldExpression
if self.is_yield_expression() {
return self.parse_yield_expression();
@@ -1499,10 +1501,11 @@ impl<'a, C: Config> ParserImpl<'a, C> {
if let Some(mut arrow_expr) = self
.try_parse_parenthesized_arrow_function_expression(allow_return_type_in_arrow_function)
{
if has_no_side_effects_comment
if let Some(comments) = no_side_effects_comments
&& let Expression::ArrowFunctionExpression(func) = &mut arrow_expr
{
func.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
return arrow_expr;
}
@@ -1510,10 +1513,11 @@ impl<'a, C: Config> ParserImpl<'a, C> {
if let Some(mut arrow_expr) = self
.try_parse_async_simple_arrow_function_expression(allow_return_type_in_arrow_function)
{
if has_no_side_effects_comment
if let Some(comments) = no_side_effects_comments
&& let Expression::ArrowFunctionExpression(func) = &mut arrow_expr
{
func.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
return arrow_expr;
}
@@ -1534,10 +1538,11 @@ impl<'a, C: Config> ParserImpl<'a, C> {
/* async */ false,
allow_return_type_in_arrow_function,
);
if has_no_side_effects_comment
if let Some(comments) = no_side_effects_comments
&& let Expression::ArrowFunctionExpression(func) = &mut arrow_expr
{
func.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
return arrow_expr;
}
@@ -1554,8 +1559,10 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let mut expr =
self.parse_conditional_expression_rest(start, lhs, allow_return_type_in_arrow_function);
if has_no_side_effects_comment {
Self::set_pure_on_function_expr(&mut expr);
if let Some(comments) = no_side_effects_comments
&& Self::set_pure_on_function_expr(&mut expr)
{
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
expr
@@ -1592,15 +1599,17 @@ impl<'a, C: Config> ParserImpl<'a, C> {
}
}
pub(crate) fn set_pure_on_function_expr(expr: &mut Expression<'a>) {
pub(crate) fn set_pure_on_function_expr(expr: &mut Expression<'a>) -> bool {
match expr {
Expression::FunctionExpression(func) => {
func.pure = true;
true
}
Expression::ArrowFunctionExpression(func) => {
func.pure = true;
true
}
_ => {}
_ => false,
}
}
+6 -4
View File
@@ -677,8 +677,8 @@ impl<'a, C: Config> ParserImpl<'a, C> {
let decl_start = self.cur_start();
// export default /* @__NO_SIDE_EFFECTS__ */ ...
let has_no_side_effects_comment =
self.lexer.trivia_builder.previous_token_has_no_side_effects_comment();
let no_side_effects_comments =
self.lexer.trivia_builder.previous_token_no_side_effects_comments();
// export default @decorator ...
if self.at(Kind::At) {
@@ -729,8 +729,9 @@ impl<'a, C: Config> ParserImpl<'a, C> {
/* r#async */ true,
FunctionKind::DefaultExport,
);
if has_no_side_effects_comment {
if let Some(comments) = no_side_effects_comments {
func.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
return ExportDefaultDeclarationKind::FunctionDeclaration(func);
}
@@ -771,8 +772,9 @@ impl<'a, C: Config> ParserImpl<'a, C> {
/* r#async */ false,
FunctionKind::DefaultExport,
);
if has_no_side_effects_comment {
if let Some(comments) = no_side_effects_comments {
func.pure = true;
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
return ExportDefaultDeclarationKind::FunctionDeclaration(func);
}
+20 -10
View File
@@ -132,8 +132,8 @@ impl<'a, C: Config> ParserImpl<'a, C> {
&mut self,
stmt_ctx: StatementContext,
) -> Statement<'a> {
let has_no_side_effects_comment =
self.lexer.trivia_builder.previous_token_has_no_side_effects_comment();
let no_side_effects_comments =
self.lexer.trivia_builder.previous_token_no_side_effects_comments();
let mut stmt = match self.cur_kind() {
Kind::LCurly => self.parse_block_statement(),
@@ -195,46 +195,56 @@ impl<'a, C: Config> ParserImpl<'a, C> {
_ => self.parse_expression_or_labeled_statement(),
};
if has_no_side_effects_comment {
Self::set_pure_on_function_stmt(&mut stmt);
if let Some(comments) = no_side_effects_comments
&& Self::set_pure_on_function_stmt(&mut stmt)
{
self.lexer.trivia_builder.mark_no_side_effects_comments_applied(comments);
}
stmt
}
fn set_pure_on_function_stmt(stmt: &mut Statement<'a>) {
fn set_pure_on_function_stmt(stmt: &mut Statement<'a>) -> bool {
match stmt {
Statement::FunctionDeclaration(func) => {
func.pure = true;
true
}
Statement::ExportDefaultDeclaration(decl) => match &mut decl.declaration {
ExportDefaultDeclarationKind::FunctionExpression(func)
| ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
func.pure = true;
true
}
ExportDefaultDeclarationKind::ArrowFunctionExpression(func) => {
func.pure = true;
true
}
_ => {}
_ => false,
},
Statement::ExportDeclaration(decl) => match &mut decl.declaration {
Declaration::FunctionDeclaration(func) => {
func.pure = true;
true
}
Declaration::VariableDeclaration(var_decl) if var_decl.kind.is_const() => {
if let Some(Some(expr)) = var_decl.declarations.first_mut().map(|d| &mut d.init)
{
Self::set_pure_on_function_expr(expr);
Self::set_pure_on_function_expr(expr)
} else {
false
}
}
_ => {}
_ => false,
},
Statement::VariableDeclaration(var_decl) if var_decl.kind.is_const() => {
if let Some(Some(expr)) = var_decl.declarations.first_mut().map(|d| &mut d.init) {
Self::set_pure_on_function_expr(expr);
Self::set_pure_on_function_expr(expr)
} else {
false
}
}
_ => {}
_ => false,
}
}
+6 -6
View File
@@ -54,7 +54,7 @@ pub struct LexerCheckpoint<'a> {
errors_snapshot: ErrorSnapshot<'a>,
tokens_len: usize,
pure_comments: Option<(usize, usize)>,
has_no_side_effects_comment: bool,
no_side_effects_comments: Option<(usize, usize)>,
}
#[derive(Debug, Clone)]
@@ -199,7 +199,7 @@ impl<'a, C: Config> Lexer<'a, C> {
errors_snapshot,
tokens_len: self.tokens.len(),
pure_comments: self.trivia_builder.previous_token_pure_comments(),
has_no_side_effects_comment: self.trivia_builder.has_no_side_effects_comment,
no_side_effects_comments: self.trivia_builder.previous_token_no_side_effects_comments(),
}
}
@@ -217,7 +217,7 @@ impl<'a, C: Config> Lexer<'a, C> {
errors_snapshot,
tokens_len: self.tokens.len(),
pure_comments: self.trivia_builder.previous_token_pure_comments(),
has_no_side_effects_comment: self.trivia_builder.has_no_side_effects_comment,
no_side_effects_comments: self.trivia_builder.previous_token_no_side_effects_comments(),
}
}
@@ -232,7 +232,7 @@ impl<'a, C: Config> Lexer<'a, C> {
self.source.set_position(checkpoint.source_position);
self.token = checkpoint.token;
self.trivia_builder.set_pure_comments(checkpoint.pure_comments);
self.trivia_builder.has_no_side_effects_comment = checkpoint.has_no_side_effects_comment;
self.trivia_builder.set_no_side_effects_comments(checkpoint.no_side_effects_comments);
}
pub fn peek_token(&mut self) -> Token {
@@ -483,7 +483,7 @@ impl<'a, C: Config> Lexer<'a, C> {
#[inline] // Make sure is inlined into `next_token`
fn read_next_token(&mut self) -> Kind {
self.trivia_builder.clear_pure_comments();
self.trivia_builder.has_no_side_effects_comment = false;
self.trivia_builder.clear_no_side_effects_comments();
let end_pos = self.source.end();
loop {
@@ -551,7 +551,7 @@ impl<'a, C: Config> Lexer<'a, C> {
#[inline]
fn read_next_jsx_attribute_value(&mut self) -> Kind {
self.trivia_builder.clear_pure_comments();
self.trivia_builder.has_no_side_effects_comment = false;
self.trivia_builder.clear_no_side_effects_comments();
let end_pos = self.source.end();
loop {
+86 -9
View File
@@ -32,7 +32,8 @@ pub struct TriviaBuilder<'a> {
/// Range of comments preceding the current token that contains pure comments.
pure_comments: Option<(usize, usize)>,
pub(super) has_no_side_effects_comment: bool,
/// Range of comments preceding the current token that contains no-side-effects comments.
no_side_effects_comments: Option<(usize, usize)>,
}
impl<'a> TriviaBuilder<'a> {
@@ -47,7 +48,7 @@ impl<'a> TriviaBuilder<'a> {
saw_newline_for_comment: true,
previous_token,
pure_comments: None,
has_no_side_effects_comment: false,
no_side_effects_comments: None,
}
}
@@ -55,8 +56,8 @@ impl<'a> TriviaBuilder<'a> {
self.pure_comments
}
pub fn previous_token_has_no_side_effects_comment(&self) -> bool {
self.has_no_side_effects_comment
pub fn previous_token_no_side_effects_comments(&self) -> Option<(usize, usize)> {
self.no_side_effects_comments
}
pub(super) fn set_pure_comments(&mut self, pure_comments: Option<(usize, usize)>) {
@@ -67,6 +68,14 @@ impl<'a> TriviaBuilder<'a> {
self.pure_comments = None;
}
pub(super) fn set_no_side_effects_comments(&mut self, comments: Option<(usize, usize)>) {
self.no_side_effects_comments = comments;
}
pub(super) fn clear_no_side_effects_comments(&mut self) {
self.no_side_effects_comments = None;
}
pub fn mark_pure_comments_applied(&mut self, (start, end): (usize, usize)) {
for comment in &mut self.comments[start..end] {
if comment.content == CommentContent::PureNotApplied {
@@ -75,6 +84,14 @@ impl<'a> TriviaBuilder<'a> {
}
}
pub fn mark_no_side_effects_comments_applied(&mut self, (start, end): (usize, usize)) {
for comment in &mut self.comments[start..end] {
if comment.content == CommentContent::NoSideEffectsNotApplied {
comment.content = CommentContent::NoSideEffects;
}
}
}
pub fn add_irregular_whitespace(&mut self, start: u32, end: u32) {
// The irregular whitespaces array is ordered; only add if not added before, to avoid
// duplicates when the parser looks ahead (e.g. `peek_token`) and rewinds, then re-lexes the
@@ -249,6 +266,7 @@ impl<'a> TriviaBuilder<'a> {
| CommentContent::Pure
| CommentContent::PureNotApplied
| CommentContent::NoSideEffects
| CommentContent::NoSideEffectsNotApplied
| CommentContent::PropertyKey
)
}
@@ -261,8 +279,12 @@ impl<'a> TriviaBuilder<'a> {
} else {
self.pure_comments = Some((index, index + 1));
}
} else if comment.is_no_side_effects() {
self.has_no_side_effects_comment = true;
} else if comment.content == CommentContent::NoSideEffectsNotApplied {
if let Some((_, end)) = &mut self.no_side_effects_comments {
*end = index + 1;
} else {
self.no_side_effects_comments = Some((index, index + 1));
}
}
}
@@ -445,7 +467,7 @@ impl<'a> TriviaBuilder<'a> {
comment.content = CommentContent::PureNotApplied;
return;
} else if rest.starts_with(b"NO_SIDE_EFFECTS__") {
comment.content = CommentContent::NoSideEffects;
comment.content = CommentContent::NoSideEffectsNotApplied;
return;
}
}
@@ -1017,6 +1039,61 @@ function bar() {}";
assert_eq!(comments[1].content, CommentContent::Pure, "second comment should remain Pure");
}
#[test]
fn no_side_effects_comment_not_applied() {
let cases = [
"/* #__NO_SIDE_EFFECTS__ */",
"/* @__NO_SIDE_EFFECTS__ */ assert.ok(true);",
"/* #__NO_SIDE_EFFECTS__ */ const foo = 1, bar = () => {};",
"/* #__NO_SIDE_EFFECTS__ */ class Foo {}",
"/* #__NO_SIDE_EFFECTS__ */ let foo = () => {};",
"/* #__NO_SIDE_EFFECTS__ */ var foo = function() {};",
"const foo /* #__NO_SIDE_EFFECTS__ */ = () => {};",
];
for source_text in cases {
let comments = get_comments(source_text);
assert_eq!(
comments[0].content,
CommentContent::NoSideEffectsNotApplied,
"{source_text}"
);
}
}
#[test]
fn no_side_effects_comment_applied() {
let cases = [
"/* #__NO_SIDE_EFFECTS__ */ function foo() {}",
"/* #__NO_SIDE_EFFECTS__ */ async function foo() {}",
"/* #__NO_SIDE_EFFECTS__ */ export function foo() {}",
"export default /* #__NO_SIDE_EFFECTS__ */ function foo() {}",
"const foo = /* #__NO_SIDE_EFFECTS__ */ function() {};",
"const foo = /* #__NO_SIDE_EFFECTS__ */ () => {};",
"/* #__NO_SIDE_EFFECTS__ */ const foo = () => {};",
"/* #__NO_SIDE_EFFECTS__ */ export const foo = () => {};",
"[/* #__NO_SIDE_EFFECTS__ */ function() {}];",
];
for source_text in cases {
let comments = get_comments(source_text);
assert_eq!(comments[0].content, CommentContent::NoSideEffects, "{source_text}");
}
}
#[test]
fn no_side_effects_comments_are_applied_independently() {
let source_text = concat!(
"/*#__NO_SIDE_EFFECTS__*/ value;",
"/*#__NO_SIDE_EFFECTS__*/ /* comment */ /*@__NO_SIDE_EFFECTS__*/ function foo() {}",
);
let comments = get_comments(source_text);
assert_eq!(comments.len(), 4);
assert_eq!(comments[0].content, CommentContent::NoSideEffectsNotApplied);
assert_eq!(comments[1].content, CommentContent::NoSideEffects);
assert_eq!(comments[2].content, CommentContent::None);
assert_eq!(comments[3].content, CommentContent::NoSideEffects);
}
#[test]
fn comment_parsing() {
let data = [
@@ -1052,9 +1129,9 @@ function bar() {}";
("/* webpackChunkName: 'my-chunk-name' */", CommentContent::Webpack),
("/* webpack */", CommentContent::None),
("/* @__PURE__ */", CommentContent::PureNotApplied),
("/* @__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
("/* @__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffectsNotApplied),
("/* #__PURE__ */", CommentContent::PureNotApplied),
("/* #__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffects),
("/* #__NO_SIDE_EFFECTS__ */", CommentContent::NoSideEffectsNotApplied),
("/* @__KEY__ */", CommentContent::PropertyKey),
("/* #__KEY__ */", CommentContent::PropertyKey),
("/*\u{a0}@__KEY__\u{a0}*/", CommentContent::PropertyKey),