Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(parser,codegen): pure annotations #9351

Merged
merged 1 commit into from
Feb 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/oxc_ast/src/ast/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,17 @@ impl Comment {
|| source_text.contains("@preserve")
}

/// `#__PURE__` Notation Specification
///
/// <https://github.com/javascript-compiler-hints/compiler-notations-spec/blob/c14f7e197cb225c9eee877143536665ce3150712/pure-notation-spec.md>
#[inline] // inline because code path is hot.
pub fn is_pure(&self, source_text: &str) -> bool {
source_text[(self.span.start + 2) as usize..]
.trim_ascii_start()
.strip_prefix(['@', '#'])
.is_some_and(|s| s.starts_with("__PURE__"))
}

/// Gets the span of the comment content.
pub fn content_span(&self) -> Span {
match self.kind {
Expand Down
9 changes: 9 additions & 0 deletions crates/oxc_ast/src/ast_impl/js.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,15 @@ impl<'a> Expression<'a> {
expr
}

/// Remove nested parentheses from this expression.
pub fn without_parentheses_mut(&mut self) -> &mut Self {
let mut expr = self;
while let Expression::ParenthesizedExpression(paran_expr) = expr {
expr = &mut paran_expr.expression;
}
expr
}

/// Returns `true` if this [`Expression`] is an [`IdentifierReference`] with specified `name`.
pub fn is_specific_id(&self, name: &str) -> bool {
match self.get_inner_expression() {
Expand Down
14 changes: 10 additions & 4 deletions crates/oxc_codegen/src/gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1430,12 +1430,15 @@ impl GenExpr for CallExpression<'_> {
let is_statement = p.start_of_stmt == p.code_len();
let is_export_default = p.start_of_default_export == p.code_len();
let mut wrap = precedence >= Precedence::New || ctx.intersects(Context::FORBID_CALL);
if precedence >= Precedence::Postfix && p.has_annotation_comment(self.span.start) {
let pure = p.options.print_annotation_comments() && self.pure;
if precedence >= Precedence::Postfix && pure {
wrap = true;
}

p.wrap(wrap, |p| {
p.print_annotation_comments(self.span.start);
if pure {
p.print_str("/* @__PURE__ */ ");
}
if is_export_default {
p.start_of_default_export = p.code_len();
} else if is_statement {
Expand Down Expand Up @@ -2198,11 +2201,14 @@ impl GenExpr for ChainExpression<'_> {
impl GenExpr for NewExpression<'_> {
fn gen_expr(&self, p: &mut Codegen, precedence: Precedence, ctx: Context) {
let mut wrap = precedence >= self.precedence();
if precedence >= Precedence::Postfix && p.has_annotation_comment(self.span.start) {
let pure = p.options.print_annotation_comments() && self.pure;
if precedence >= Precedence::Postfix && pure {
wrap = true;
}
p.wrap(wrap, |p| {
p.print_annotation_comments(self.span.start);
if pure {
p.print_str("/* @__PURE__ */ ");
}
p.print_space_before_identifier();
p.add_source_mapping(self.span);
p.print_str("new");
Expand Down
4 changes: 2 additions & 2 deletions crates/oxc_codegen/tests/integration/esbuild.rs
Original file line number Diff line number Diff line change
Expand Up @@ -552,7 +552,7 @@ fn test_pure_comment() {

test("new (function() {})", "new function() {}();\n");
test("new (function() {})()", "new function() {}();\n");
test("/*@__PURE__*/new (function() {})()", "/*@__PURE__*/ new function() {}();\n");
test("/*@__PURE__*/new (function() {})()", "/* @__PURE__ */ new function() {}();\n");

test("export default (function() { foo() })", "export default (function() {\n\tfoo();\n});\n");
test(
Expand All @@ -561,7 +561,7 @@ fn test_pure_comment() {
);
test(
"export default /*@__PURE__*/(function() { foo() })()",
"export default /*@__PURE__*/ (function() {\n\tfoo();\n})();\n",
"export default /* @__PURE__ */ (function() {\n\tfoo();\n})();\n",
);
}

Expand Down
56 changes: 28 additions & 28 deletions crates/oxc_codegen/tests/integration/snapshots/pure_comments.snap
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,13 @@ isFunction(options)
: options

----------
isFunction(options) ? /*#__PURE__*/ (() => extend({ name: options.name }, extraOptions, { setup: options }))() : options;
isFunction(options) ? /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() : options;

########## 10
isFunction(options) ? /*#__PURE__*/ (() => extend({ name: options.name }, extraOptions, { setup: options }))() : options;

----------
isFunction(options) ? /*#__PURE__*/ (() => extend({ name: options.name }, extraOptions, { setup: options }))() : options;
isFunction(options) ? /* @__PURE__ */ (() => extend({ name: options.name }, extraOptions, { setup: options }))() : options;

########## 11

Expand All @@ -194,18 +194,18 @@ const obj = {
const p = /*#__PURE__*/ Promise.resolve();

----------
const obj = { props: /*#__PURE__*/ extend({}, TransitionPropsValidators, {
const obj = { props: /* @__PURE__ */ extend({}, TransitionPropsValidators, {
tag: String,
moveClass: String
}) };
const p = /*#__PURE__*/ Promise.resolve();
const p = /* @__PURE__ */ Promise.resolve();

########## 12

const staticCacheMap = /*#__PURE__*/ new WeakMap()

----------
const staticCacheMap = /*#__PURE__*/ new WeakMap();
const staticCacheMap = /* @__PURE__ */ new WeakMap();

########## 13

Expand All @@ -217,7 +217,7 @@ const builtInSymbols = new Set(

----------
const builtInSymbols = new Set(
/*#__PURE__*/ Object.getOwnPropertyNames(Symbol).filter((key) => key !== 'arguments' && key !== 'caller')
/* @__PURE__ */ Object.getOwnPropertyNames(Symbol).filter((key) => key !== 'arguments' && key !== 'caller')
);

########## 14
Expand Down Expand Up @@ -267,7 +267,7 @@ const defineSSRCustomElement = () => {

----------
const defineSSRCustomElement = () => {
return /* @__PURE__ */ /* @__NO_SIDE_EFFECTS__ */ /* #__NO_SIDE_EFFECTS__ */ defineCustomElement(options, extraOptions, hydrate);
return /* @__PURE__ */ defineCustomElement(options, extraOptions, hydrate);
};

########## 20
Expand All @@ -276,7 +276,7 @@ const defineSSRCustomElement = () => {
React.forwardRef((props, ref) => {});

----------
const Component = /* #__PURE__*/ React.forwardRef((props, ref) => {});
const Component = /* @__PURE__ */ React.forwardRef((props, ref) => {});

########## 21

Expand Down Expand Up @@ -347,18 +347,18 @@ let at_yes = /* @__PURE__ */ foo(bar);
let at_no = /* @__PURE__ */ foo(bar());
let new_at_yes = /* @__PURE__ */ new foo(bar);
let new_at_no = /* @__PURE__ */ new foo(bar());
let nospace_at_yes = /*@__PURE__*/ foo(bar);
let nospace_at_no = /*@__PURE__*/ foo(bar());
let nospace_new_at_yes = /*@__PURE__*/ new foo(bar);
let nospace_new_at_no = /*@__PURE__*/ new foo(bar());
let num_yes = /* #__PURE__ */ foo(bar);
let num_no = /* #__PURE__ */ foo(bar());
let new_num_yes = /* #__PURE__ */ new foo(bar);
let new_num_no = /* #__PURE__ */ new foo(bar());
let nospace_num_yes = /*#__PURE__*/ foo(bar);
let nospace_num_no = /*#__PURE__*/ foo(bar());
let nospace_new_num_yes = /*#__PURE__*/ new foo(bar);
let nospace_new_num_no = /*#__PURE__*/ new foo(bar());
let nospace_at_yes = /* @__PURE__ */ foo(bar);
let nospace_at_no = /* @__PURE__ */ foo(bar());
let nospace_new_at_yes = /* @__PURE__ */ new foo(bar);
let nospace_new_at_no = /* @__PURE__ */ new foo(bar());
let num_yes = /* @__PURE__ */ foo(bar);
let num_no = /* @__PURE__ */ foo(bar());
let new_num_yes = /* @__PURE__ */ new foo(bar);
let new_num_no = /* @__PURE__ */ new foo(bar());
let nospace_num_yes = /* @__PURE__ */ foo(bar);
let nospace_num_no = /* @__PURE__ */ foo(bar());
let nospace_new_num_yes = /* @__PURE__ */ new foo(bar);
let nospace_new_num_no = /* @__PURE__ */ new foo(bar());
let dot_yes = /* @__PURE__ */ foo(sideEffect()).dot(bar);
let dot_no = /* @__PURE__ */ foo(sideEffect()).dot(bar());
let new_dot_yes = /* @__PURE__ */ new foo(sideEffect()).dot(bar);
Expand All @@ -383,14 +383,14 @@ let new_nested_no = [
/* @__PURE__ */ new foo(bar()),
2
];
let single_at_yes = /* @__PURE__*/ foo(bar);
let single_at_no = /* @__PURE__*/ foo(bar());
let new_single_at_yes = /* @__PURE__*/ new foo(bar);
let new_single_at_no = /* @__PURE__*/ new foo(bar());
let single_num_yes = /* #__PURE__*/ foo(bar);
let single_num_no = /* #__PURE__*/ foo(bar());
let new_single_num_yes = /* #__PURE__*/ new foo(bar);
let new_single_num_no = /* #__PURE__*/ new foo(bar());
let single_at_yes = /* @__PURE__ */ foo(bar);
let single_at_no = /* @__PURE__ */ foo(bar());
let new_single_at_yes = /* @__PURE__ */ new foo(bar);
let new_single_at_no = /* @__PURE__ */ new foo(bar());
let single_num_yes = /* @__PURE__ */ foo(bar);
let single_num_no = /* @__PURE__ */ foo(bar());
let new_single_num_yes = /* @__PURE__ */ new foo(bar);
let new_single_num_no = /* @__PURE__ */ new foo(bar());
let bad_no = foo(bar);
let new_bad_no = new foo(bar);
let parens_no = foo(bar);
Expand Down
4 changes: 4 additions & 0 deletions crates/oxc_codegen/tests/integration/tester.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ pub fn test(source_text: &str, expected: &str) {
test_options(source_text, expected, CodegenOptions::default());
}

pub fn test_same(source_text: &str) {
test(source_text, source_text);
}

pub fn test_options(source_text: &str, expected: &str, options: CodegenOptions) {
test_options_with_source_type(source_text, expected, SourceType::jsx(), options);
}
Expand Down
25 changes: 24 additions & 1 deletion crates/oxc_codegen/tests/integration/unit.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use oxc_codegen::CodegenOptions;

use crate::tester::{test, test_minify, test_minify_same, test_options};
use crate::tester::{test, test_minify, test_minify_same, test_options, test_same};

#[test]
fn decl() {
Expand Down Expand Up @@ -356,6 +356,29 @@ fn vite_special_comments() {
);
}

// <https://github.com/javascript-compiler-hints/compiler-notations-spec/blob/main/pure-notation-spec.md#semantics>
#[test]
fn pure_comment() {
test_same("/* @__PURE__ */ pureOperation();\n");
test_same("/* @__PURE__ */ new PureConsutrctor();\n");
test("/* @__PURE__ */\npureOperation();\n", "/* @__PURE__ */ pureOperation();\n");
test(
"/* @__PURE__ The comment may contain additional text */ pureOperation();\n",
"/* @__PURE__ */ pureOperation();\n",
);
test("const foo /* #__PURE__ */ = pureOperation();", "const foo = pureOperation();\n"); // INVALID: "=" not allowed after annotation

test_same("/* #__PURE__ */ function foo() {}\n"); // FIXME: can be removed. // INVALID: Only allowed for calls

test("/* @__PURE__ */ (foo());", "/* @__PURE__ */ foo();\n");
test("/* @__PURE__ */ (new Foo());\n", "/* @__PURE__ */ new Foo();\n");
test("/*#__PURE__*/ (foo(), bar());", "foo(), bar();\n"); // INVALID, there is a comma expression in the parentheses

test_same("/* @__PURE__ */ a.b().c.d();\n");
test("/* @__PURE__ */ a().b;", "a().b;\n"); // INVALID, it does not end with a call
test_same("(/* @__PURE__ */ a()).b;\n");
}

// followup from https://github.com/oxc-project/oxc/pull/6422
#[test]
fn in_expr_in_sequence_in_for_loop_init() {
Expand Down
25 changes: 24 additions & 1 deletion crates/oxc_parser/src/js/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1102,6 +1102,7 @@ impl<'a> ParserImpl<'a> {
&mut self,
allow_return_type_in_arrow_function: bool,
) -> Result<Expression<'a>> {
let has_pure_comment = self.lexer.trivia_builder.previous_token_has_pure_comment();
// [+Yield] YieldExpression
if self.is_yield_expression() {
return self.parse_yield_expression();
Expand Down Expand Up @@ -1141,7 +1142,29 @@ impl<'a> ParserImpl<'a> {
);
}

self.parse_conditional_expression_rest(span, lhs, allow_return_type_in_arrow_function)
let mut expr =
self.parse_conditional_expression_rest(span, lhs, allow_return_type_in_arrow_function)?;

if has_pure_comment {
Self::set_pure_on_call_or_new_expr(&mut expr);
}

Ok(expr)
}

fn set_pure_on_call_or_new_expr(expr: &mut Expression<'a>) {
match &mut expr.without_parentheses_mut() {
Expression::CallExpression(call_expr) => {
call_expr.pure = true;
}
Expression::NewExpression(new_expr) => {
new_expr.pure = true;
}
Expression::BinaryExpression(binary_expr) => {
Self::set_pure_on_call_or_new_expr(&mut binary_expr.left);
}
_ => {}
}
}

fn parse_assignment_expression_recursive(
Expand Down
14 changes: 9 additions & 5 deletions crates/oxc_parser/src/lexer/comment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ impl<'a> Lexer<'a> {
if next_byte != LS_OR_PS_FIRST {
// `\r` or `\n`
self.trivia_builder
.add_line_comment(self.token.start, self.source.offset_of(pos));
.add_line_comment(self.token.start, self.source.offset_of(pos), self.source.whole());
// SAFETY: Safe to consume `\r` or `\n` as both are ASCII
pos = unsafe { pos.add(1) };
// We've found the end. Do not continue searching.
Expand All @@ -51,7 +51,7 @@ impl<'a> Lexer<'a> {
if matches!(next2, LS_BYTES_2_AND_3 | PS_BYTES_2_AND_3) {
// Irregular line break
self.trivia_builder
.add_line_comment(self.token.start, self.source.offset_of(pos));
.add_line_comment(self.token.start, self.source.offset_of(pos), self.source.whole());
// Advance `pos` to after this char.
// SAFETY: `0xE2` is always 1st byte of a 3-byte UTF-8 char,
// so consuming 3 bytes will place `pos` on next UTF-8 char boundary.
Expand All @@ -70,7 +70,7 @@ impl<'a> Lexer<'a> {
}
},
handle_eof: {
self.trivia_builder.add_line_comment(self.token.start, self.offset());
self.trivia_builder.add_line_comment(self.token.start, self.offset(), self.source.whole());
return Kind::Skip;
},
};
Expand Down Expand Up @@ -146,7 +146,7 @@ impl<'a> Lexer<'a> {
},
};

self.trivia_builder.add_block_comment(self.token.start, self.offset());
self.trivia_builder.add_block_comment(self.token.start, self.offset(), self.source.whole());
Kind::Skip
}

Expand All @@ -166,7 +166,11 @@ impl<'a> Lexer<'a> {
if let Some(index) = finder.find(remaining) {
// SAFETY: `pos + index + 2` is end of `*/`, so a valid `SourcePosition`
self.source.set_position(unsafe { pos.add(index + 2) });
self.trivia_builder.add_block_comment(self.token.start, self.offset());
self.trivia_builder.add_block_comment(
self.token.start,
self.offset(),
self.source.whole(),
);
Kind::Skip
} else {
self.source.advance_to_end();
Expand Down
1 change: 1 addition & 0 deletions crates/oxc_parser/src/lexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ impl<'a> Lexer<'a> {
/// Read each char and set the current token
/// Whitespace and line terminators are skipped
fn read_next_token(&mut self) -> Kind {
self.trivia_builder.has_pure_comment = false;
loop {
let offset = self.offset();
self.token.start = offset;
Expand Down
Loading
Loading