diff --git a/crates/oxc_angular_compiler/src/linker/mod.rs b/crates/oxc_angular_compiler/src/linker/mod.rs index 0e9e43695..86c055ef7 100644 --- a/crates/oxc_angular_compiler/src/linker/mod.rs +++ b/crates/oxc_angular_compiler/src/linker/mod.rs @@ -35,11 +35,13 @@ use oxc_allocator::Allocator; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, BindingPattern, CallExpression, Expression, ObjectExpression, + Argument, ArrayExpressionElement, BindingPattern, CallExpression, Decorator, + ExportDefaultDeclaration, Expression, ExpressionStatement, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, Statement, }; +use oxc_ast_visit::{Visit, walk}; use oxc_parser::Parser; -use oxc_span::{GetSpan, SourceType}; +use oxc_span::{GetSpan, SourceType, Span}; use crate::AngularVersion; use crate::optimizer::Edit; @@ -143,7 +145,10 @@ pub fn link_with_options( LinkResult { code: linked_code, map, linked: true } } -/// Recursively walk the AST to find all ɵɵngDeclare* calls and generate edits. +/// Find all ɵɵngDeclare* calls and generate edits. +/// +/// Like Babel's linker, which visits every `CallExpression`, this visits every call wherever it sits +/// (class members, array and object literals, IIFE callees, conditions, decorators, ...). fn collect_declaration_edits( program: &Program<'_>, source: &str, @@ -151,242 +156,91 @@ fn collect_declaration_edits( options: &LinkOptions, edits: &mut Vec, ) { - for stmt in &program.body { - walk_statement(stmt, source, filename, options, edits); + DeclarationCollector { + source, + filename, + options, + edits, + statement_start: None, + decorator: None, } + .visit_program(program); } -/// Walk a statement looking for ɵɵngDeclare* calls. -fn walk_statement( - stmt: &Statement<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match stmt { - Statement::ExpressionStatement(expr_stmt) => { - walk_expression(&expr_stmt.expression, source, filename, options, edits); - } - Statement::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); - } - Statement::VariableDeclaration(var_decl) => { - for decl in &var_decl.declarations { - if let Some(init) = &decl.init { - walk_expression(init, source, filename, options, edits); - } - } - } - Statement::ReturnStatement(ret) => { - if let Some(ref arg) = ret.argument { - walk_expression(arg, source, filename, options, edits); - } - } - Statement::BlockStatement(block) => { - for stmt in &block.body { - walk_statement(stmt, source, filename, options, edits); - } - } - Statement::IfStatement(if_stmt) => { - walk_statement(&if_stmt.consequent, source, filename, options, edits); - if let Some(ref alt) = if_stmt.alternate { - walk_statement(alt, source, filename, options, edits); - } - } - Statement::ForStatement(for_stmt) => { - walk_statement(&for_stmt.body, source, filename, options, edits); - } - Statement::ForInStatement(for_in) => { - walk_statement(&for_in.body, source, filename, options, edits); - } - Statement::ForOfStatement(for_of) => { - walk_statement(&for_of.body, source, filename, options, edits); - } - Statement::WhileStatement(while_stmt) => { - walk_statement(&while_stmt.body, source, filename, options, edits); - } - Statement::DoWhileStatement(do_while) => { - walk_statement(&do_while.body, source, filename, options, edits); - } - Statement::TryStatement(try_stmt) => { - for stmt in &try_stmt.block.body { - walk_statement(stmt, source, filename, options, edits); - } - if let Some(ref handler) = try_stmt.handler { - for stmt in &handler.body.body { - walk_statement(stmt, source, filename, options, edits); - } - } - if let Some(ref finalizer) = try_stmt.finalizer { - for stmt in &finalizer.body { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::SwitchStatement(switch_stmt) => { - for case in &switch_stmt.cases { - for stmt in &case.consequent { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::LabeledStatement(labeled) => { - walk_statement(&labeled.body, source, filename, options, edits); - } - Statement::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Statement::ExportDeclaration(export_decl) => { - walk_declaration(&export_decl.declaration, source, filename, options, edits); - } - Statement::ExportDefaultDeclaration(export_default) => match &export_default.declaration { - oxc_ast::ast::ExportDefaultDeclarationKind::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); - } - oxc_ast::ast::ExportDefaultDeclarationKind::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - _ => { - if let Some(expr) = export_default.declaration.as_expression() { - walk_expression(expr, source, filename, options, edits); - } - } - }, - _ => {} - } +struct DeclarationCollector<'s> { + source: &'s str, + filename: &'s str, + options: &'s LinkOptions, + edits: &'s mut Vec, + /// The expression of the statement being visited, and whether a `;` may go before it: true for a statement in + /// a statement list, false for `export default` or the bare body of `if`, a loop or a label + statement_start: Option<(Span, bool)>, + /// The expression of the decorator being visited + decorator: Option, } -/// Walk a class body looking for ɵɵngDeclare* calls in property definitions and static blocks. -fn walk_class_body( - body: &oxc_ast::ast::ClassBody<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - for element in &body.body { - if let oxc_ast::ast::ClassElement::PropertyDefinition(prop) = element - && let Some(ref value) = prop.value - { - walk_expression(value, source, filename, options, edits); - } - if let oxc_ast::ast::ClassElement::StaticBlock(block) = element { - for stmt in &block.body { - walk_statement(stmt, source, filename, options, edits); - } - } - if let oxc_ast::ast::ClassElement::MethodDefinition(method) = element - && let Some(ref body) = method.value.body - { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } +impl DeclarationCollector<'_> { + fn visit_statement_expression<'a>(&mut self, expr: &Expression<'a>, is_statement: bool) { + let outer = self.statement_start.replace((expr.span(), is_statement)); + self.visit_expression(expr); + self.statement_start = outer; } } -/// Walk a declaration (from export statements) looking for ɵɵngDeclare* calls. -fn walk_declaration( - decl: &oxc_ast::ast::Declaration<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match decl { - oxc_ast::ast::Declaration::VariableDeclaration(var_decl) => { - for d in &var_decl.declarations { - if let Some(init) = &d.init { - walk_expression(init, source, filename, options, edits); - } - } - } - oxc_ast::ast::Declaration::FunctionDeclaration(func_decl) => { - if let Some(ref body) = func_decl.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); +impl<'a> Visit<'a> for DeclarationCollector<'_> { + fn visit_statements(&mut self, stmts: &oxc_allocator::Vec<'a, Statement<'a>>) { + for stmt in stmts { + match stmt { + Statement::ExpressionStatement(stmt) => { + self.visit_statement_expression(&stmt.expression, true); } + _ => self.visit_statement(stmt), } } - oxc_ast::ast::Declaration::ClassDeclaration(class_decl) => { - walk_class_body(&class_decl.body, source, filename, options, edits); + } + + /// Only reached outside a statement list, such as `if (c) x;`, where a `;` would become the whole body + fn visit_expression_statement(&mut self, stmt: &ExpressionStatement<'a>) { + self.visit_statement_expression(&stmt.expression, false); + } + + fn visit_decorator(&mut self, decorator: &Decorator<'a>) { + let outer = self.decorator.replace(decorator.expression.span()); + walk::walk_decorator(self, decorator); + self.decorator = outer; + } + + fn visit_export_default_declaration(&mut self, decl: &ExportDefaultDeclaration<'a>) { + match decl.declaration.as_expression() { + Some(expr) => self.visit_statement_expression(expr, false), + None => walk::walk_export_default_declaration(self, decl), } - _ => {} } -} -/// Walk an expression looking for ɵɵngDeclare* calls. -fn walk_expression( - expr: &Expression<'_>, - source: &str, - filename: &str, - options: &LinkOptions, - edits: &mut Vec, -) { - match expr { - Expression::CallExpression(call) => { - if let Some(name) = get_declare_name(call) - && let Some(edit) = link_declaration(name, call, source, filename, options) + fn visit_call_expression(&mut self, call: &CallExpression<'a>) { + if let Some(name) = get_declare_name(call) + && let Some(mut edit) = + link_declaration(name, call, self.source, self.filename, self.options) + { + // A linked factory is a `function` expression: where it starts a longer statement, such as + // `ɵɵngDeclareFactory(…).name;`, it would be read as a declaration, so it gets parentheses (and, in a + // statement list, a `;` so that they do not call the line before) + // `@function …` does not parse either + if self.decorator.is_some_and(|expr| expr.start == call.span.start) { + edit.replacement = format!("({})", edit.replacement); + } else if let Some((expr, is_statement)) = self.statement_start + && expr.start == call.span.start + && expr != call.span { - edits.push(edit); - return; + let semicolon = if is_statement { ";" } else { "" }; + edit.replacement = format!("{semicolon}({})", edit.replacement); } - // Walk arguments recursively - for arg in &call.arguments { - if let Argument::SpreadElement(_) = arg { - continue; - } - walk_expression(arg.to_expression(), source, filename, options, edits); - } - } - Expression::AssignmentExpression(assign) => { - walk_expression(&assign.right, source, filename, options, edits); - } - Expression::SequenceExpression(seq) => { - for expr in &seq.expressions { - walk_expression(expr, source, filename, options, edits); - } - } - Expression::ConditionalExpression(cond) => { - walk_expression(&cond.consequent, source, filename, options, edits); - walk_expression(&cond.alternate, source, filename, options, edits); - } - Expression::LogicalExpression(logical) => { - walk_expression(&logical.left, source, filename, options, edits); - walk_expression(&logical.right, source, filename, options, edits); - } - Expression::ParenthesizedExpression(paren) => { - walk_expression(&paren.expression, source, filename, options, edits); - } - Expression::ArrowFunctionExpression(arrow) => { - if let Some(body) = arrow.get_function_body() { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } else if let Some(expr) = arrow.get_expression() { - walk_expression(expr, source, filename, options, edits); - } - } - Expression::FunctionExpression(func) => { - if let Some(body) = &func.body { - for stmt in &body.statements { - walk_statement(stmt, source, filename, options, edits); - } - } - } - Expression::ClassExpression(class_expr) => { - walk_class_body(&class_expr.body, source, filename, options, edits); + // The edit replaces the whole call, so a declaration nested in its arguments + // must not get an overlapping edit of its own. + self.edits.push(edit); + return; } - _ => {} + walk::walk_call_expression(self, call); } } diff --git a/crates/oxc_angular_compiler/tests/linker_test.rs b/crates/oxc_angular_compiler/tests/linker_test.rs index b98c0f551..9d6ed7251 100644 --- a/crates/oxc_angular_compiler/tests/linker_test.rs +++ b/crates/oxc_angular_compiler/tests/linker_test.rs @@ -3,6 +3,8 @@ use oxc_allocator::Allocator; use oxc_angular_compiler::AngularVersion; use oxc_angular_compiler::linker::{LinkOptions, link, link_with_options}; +use oxc_parser::Parser; +use oxc_span::SourceType; /// Helper to build a ɵɵngDeclareDirective source with a given inputs block. fn make_directive_source(inputs_block: &str) -> String { @@ -409,6 +411,83 @@ MyDir.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "17.0.0 ); } +/// Babel's linker links every declaration call; Deepkit's runtime types keep a copy of each static initializer in a +/// `__type` array, whose declarations must not stay unlinked (their `ngImport: i0` keeps all of @angular/core) +#[test] +fn test_link_declarations_nested_in_arrays_and_objects() { + let allocator = Allocator::default(); + let code = r#"import * as i0 from "@angular/core"; +class Cmp { + static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); } + static { this.__type = ['ɵfac', function () { return i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }); }]; } +} +const table = { factory: () => i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: Cmp, deps: [], target: i0.ɵɵFactoryTarget.Component }) }; +"#; + let result = link(&allocator, code, "test.mjs"); + assert!(result.linked); + assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); + assert!(!result.code.contains("ngImport"), "{}", result.code); +} + +/// Like Babel's linker, every call is visited, whatever expression or statement holds it. +#[test] +fn test_link_declarations_in_any_position() { + let d = r#"i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.0.0", ngImport: i0, type: Svc, providedIn: "root" })"#; + let positions = [ + format!("(function () {{ Svc.ɵprov = {d}; }})();"), + format!("(() => {{ Svc.ɵprov = {d}; }})();"), + format!("f(...[{d}]);"), + format!("if ({d}) {{}}"), + format!("for (let x = {d}; {d}; {d}) break;"), + format!("for (const x of [{d}]);"), + format!("switch ({d}) {{ case {d}: break; }}"), + format!("class A extends ({d}, Object) {{ [{d}] = 1; static accessor p = {d}; }}"), + format!("({{ [{d}]: 1 }});"), + format!("tag`${{{d}}}`;"), + format!("f?.({d}); a?.[{d}];"), + format!("function* g(a = {d}) {{ yield {d}; }}"), + format!("const {{ a = {d} }} = {{}}; x[{d}] = 1;"), + ]; + for position in positions { + let allocator = Allocator::default(); + let code = format!("import * as i0 from \"@angular/core\";\nclass Svc {{}}\n{position}\n"); + let result = link(&allocator, &code, "test.mjs"); + assert!(!result.code.contains("ɵɵngDeclare"), "every declaration links:\n{}", result.code); + } +} + +/// A linked factory is a `function` expression, so where it starts a longer statement it gets parentheses, like +/// Babel's output: `function X_Factory() {}.x;` would not parse, and `export default function … + ""` would +/// export the function +#[test] +fn test_link_factory_starting_a_statement_keeps_its_meaning() { + let d = r#"i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: X, deps: [], target: i0.ɵɵFactoryTarget.Injectable })"#; + for (position, expected) in [ + (format!("let y = 1\n{d}.x;"), "let y = 1\n;(function X_Factory("), + (format!("{d} + 1;"), "\n;(function X_Factory("), + (format!("{d}.call(null);"), "\n;(function X_Factory("), + (format!("export default {d} + \"\";"), "export default (function X_Factory("), + // a bare body gets no `;`, which would become the body itself + (format!("if (c) {d}.x;"), "if (c) (function X_Factory("), + (format!("if (c) {{}} else {d}.x;"), "else (function X_Factory("), + (format!("l: {d}.x;"), "l: (function X_Factory("), + (format!("switch (c) {{ case 1: {d}.x; }}"), "case 1: ;(function X_Factory("), + (format!("const f = () => {d}.x;"), "const f = () => function X_Factory("), + // nor does `@function …` + (format!("@{d} class Y {{}}"), "@(function X_Factory("), + // the whole statement, or not its start: as before + (format!("{d};"), "\nfunction X_Factory("), + (format!("const z = {d}.x;"), "const z = function X_Factory("), + ] { + let allocator = Allocator::default(); + let code = format!("import * as i0 from \"@angular/core\";\nclass X {{}}\n{position}\n"); + let result = link(&allocator, &code, "test.mjs"); + assert!(result.code.contains(expected), "expected `{expected}`:\n{}", result.code); + let reparsed = Parser::new(&allocator, &result.code, SourceType::mjs()).parse(); + assert!(reparsed.diagnostics.is_empty(), "{}", result.code); + } +} + /// Every `ɵɵdefine*` call carries `/*@__PURE__*/`, like the output of Angular's /// babel linker (`@angular/compiler-cli/linker/babel` 22.2.3) on these declarations. /// Factories (function expressions) and `ɵsetClassMetadata` get no annotation.