From 13252e76d0c987ee2e4cbbe43e002f525ccd4b76 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 22:48:53 +0800 Subject: [PATCH 01/12] fix(directive): recognise signal APIs by their @angular/core import, like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked against @angular/compiler-cli 22.1.7 (probes are in the fixture: `signal-*`, `shorthandRef-*`): - Signal members were compiled by the name of the function they call. So `value = input(0)` with a local `input`, one imported from './other', an undeclared one, or `local.input(0)` became a signal input (`value: [1, "value"]`), a foreign `viewChild()` a signal query, and so on, where ngtsc sees a plain property; an aliased `import {input as inp}` was missed. Like ngtsc's `tryParseInitializerApi`, `input` / `model` / `output` / the query functions (`outputFromObservable` from `@angular/core/rxjs-interop`) now count only when imported from their module by name, under any alias, or through a namespace import (`ng.input()`), plus the `.required` forms. This holds for the `ɵdir`/`ɵcmp` definition, the `.d.ts`, the synthetic `setClassMetadata` / JIT `propDecorators`, and NAPI's `extractComponentMetadataSync` / class metadata. The public `extract_*` / `build_prop_decorators_metadata` functions, which aren't given the file's imports, still match by name. `ng.input(0, {alias})` and `ng.model(0, {alias})` now read their alias too. - A transform that resolves to an imported function or a global is emitted by the name ngtsc gives it (the reference's identity in the file): `const transform = booleanAttribute` with `{transform}` or `transform: t` now emits `booleanAttribute`, not `transform` / `t`; `import {booleanAttribute as ba}` emits `ba`; and `o.t` in a helper called with `{t: booleanAttribute}` compiles to `booleanAttribute` instead of being reported. A value computed from an import (a call, a member, a condition on one) is still kept as written. - A shorthand property naming an import (`{transform}` with `import {toNum as transform}`, `{name: 'x', alias}` with an imported `alias`) can't be evaluated by ngtsc (TypeScript returns the alias symbol, which has no value declaration). oxc now treats it the same way: "Input transform must be a function" for a transform, and an imported alias or `required` is ignored, as ngtsc does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/class_metadata/builders.rs | 82 +- .../src/class_metadata/mod.rs | 1 + .../src/component/transform.rs | 103 +- .../src/directive/decorator.rs | 132 +- .../src/directive/evaluator.rs | 58 +- .../oxc_angular_compiler/src/directive/mod.rs | 4 + .../src/directive/property_decorators.rs | 369 ++--- crates/oxc_angular_compiler/src/lib.rs | 6 +- .../tests/decorator_metadata_ngtsc_test.rs | 60 +- .../fixtures/decorator_metadata_ngtsc.json | 1277 +++++++++++++++++ .../tests/jit_signal_initializer_test.rs | 23 + .../tests/signal_member_jit_metadata_test.rs | 24 + napi/angular-compiler/src/lib.rs | 8 +- .../test/extract-component-metadata.test.ts | 35 + 14 files changed, 1727 insertions(+), 455 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index 120427316..aabbd167b 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -12,8 +12,8 @@ use oxc_str::Ident; use crate::component::{ImportMap, NamespaceRegistry, R3DependencyMetadata}; use crate::directive::{ - R3InputMetadata, StringConsts, resolve_template_literal, try_parse_signal_input, - try_parse_signal_model, try_parse_signal_output, unwrap_initializer_api_expr, + QUERY_APIS, R3InputMetadata, StringConsts, initializer_api_call, resolve_template_literal, + try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, }; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, @@ -407,11 +407,32 @@ pub fn build_ctor_params_metadata<'a>( /// /// Creates: `{ propName: [{ type: Input, args: [...] }], ... }` /// Returns `None` if no properties have Angular decorators. +/// +/// Without the file's imports, a signal member is recognised by the name of +/// the function it calls; [`build_prop_decorators_metadata_in`] recognises +/// only Angular's, like ngtsc. pub fn build_prop_decorators_metadata<'a>( allocator: &'a Allocator, class: &Class<'a>, source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, +) -> Option> { + build_prop_decorators_metadata_in(allocator, class, source_text, namespace_registry, None) +} + +/// [`build_prop_decorators_metadata`] for a class in the file `consts` was +/// collected from. +/// +/// A signal member (`input()`, `viewChild()`, ...) gets a synthetic decorator +/// only when it calls Angular's function, imported from `@angular/core` (or +/// `@angular/core/rxjs-interop`) by name, under any alias, or through a +/// namespace import. +pub fn build_prop_decorators_metadata_in<'a>( + allocator: &'a Allocator, + class: &Class<'a>, + source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, + consts: Option<&StringConsts<'a>>, ) -> Option> { const ANGULAR_PROP_DECORATORS: &[&str] = &[ "Input", @@ -480,6 +501,7 @@ pub fn build_prop_decorators_metadata<'a>( &prop_name, source_text, namespace_registry, + consts, ) { prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); @@ -505,13 +527,16 @@ fn build_initializer_api_prop_decorators<'a>( property_name: &Ident<'a>, source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, + consts: Option<&StringConsts<'a>>, ) -> Option> { let mut decorators = AllocVec::new_in(&allocator); - if let Some(input) = try_parse_signal_input(allocator, value, property_name.clone()) { + if let Some(input) = try_parse_signal_input(value, property_name.clone(), consts) { // input() / input.required() → `Input({ isSignal, alias, required })` decorators.push(build_signal_input_decorator(allocator, namespace_registry, &input)); - } else if let Some(model) = try_parse_signal_model(allocator, value, property_name.clone()) { + } else if let Some(model) = + try_parse_signal_model(allocator, value, property_name.clone(), consts) + { // model() → `Input({ isSignal, alias, required })` + `Output("Change")` decorators.push(build_signal_input_decorator(allocator, namespace_registry, &model.input)); decorators.push(build_core_decorator_with_string_arg( @@ -520,7 +545,8 @@ fn build_initializer_api_prop_decorators<'a>( "Output", model.output.1.clone(), )); - } else if let Some((_, binding)) = try_parse_signal_output(value, property_name.clone()) { + } else if let Some((_, binding)) = try_parse_signal_output(value, property_name.clone(), consts) + { // output() / outputFromObservable() → `Output("")` decorators.push(build_core_decorator_with_string_arg( &allocator, @@ -529,7 +555,7 @@ fn build_initializer_api_prop_decorators<'a>( binding, )); } else if let Some(query) = - build_signal_query_decorator(allocator, value, source_text, namespace_registry) + build_signal_query_decorator(allocator, value, source_text, namespace_registry, consts) { decorators.push(query); } @@ -589,11 +615,15 @@ fn build_signal_query_decorator<'a>( value: &Expression<'a>, source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, + consts: Option<&StringConsts<'a>>, ) -> Option> { - let Expression::CallExpression(call) = unwrap_initializer_api_expr(value) else { - return None; + let ((function, _), _, call) = initializer_api_call(value, consts, &QUERY_APIS)?; + let decorator_name = match function { + "viewChild" => "ViewChild", + "viewChildren" => "ViewChildren", + "contentChild" => "ContentChild", + _ => "ContentChildren", }; - let decorator_name = signal_query_decorator_name(&call.callee)?; // Predicate: the first positional argument (required), reused as-is. A query with // no locator is invalid (ngc errors); skip synthesis rather than emit a malformed @@ -626,40 +656,6 @@ fn build_signal_query_decorator<'a>( Some(build_core_decorator(allocator, namespace_registry, decorator_name, args)) } -/// Map a signal-query initializer callee to its decorator name, handling the direct -/// (`viewChild()`), required (`viewChild.required()`), and namespaced (`core.viewChild()`) -/// forms. -fn signal_query_decorator_name(callee: &Expression<'_>) -> Option<&'static str> { - fn name_of(function: &str) -> Option<&'static str> { - match function { - "viewChild" => Some("ViewChild"), - "viewChildren" => Some("ViewChildren"), - "contentChild" => Some("ContentChild"), - "contentChildren" => Some("ContentChildren"), - _ => None, - } - } - - match callee { - Expression::Identifier(id) => name_of(id.name.as_str()), - Expression::StaticMemberExpression(member) => { - if member.property.name == "required" { - match &member.object { - Expression::Identifier(id) => name_of(id.name.as_str()), - Expression::StaticMemberExpression(inner) => { - name_of(inner.property.name.as_str()) - } - _ => None, - } - } else { - // Namespaced call: `core.viewChild(...)`. - name_of(member.property.name.as_str()) - } - } - _ => None, - } -} - /// Build `{ type: i0., args: [""] }` for a decorator taking a single string. fn build_core_decorator_with_string_arg<'a>( allocator: &'a Allocator, diff --git a/crates/oxc_angular_compiler/src/class_metadata/mod.rs b/crates/oxc_angular_compiler/src/class_metadata/mod.rs index 71ed1d24c..d0339fd5b 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/mod.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/mod.rs @@ -16,6 +16,7 @@ mod metadata; pub use builders::{ build_ctor_params_metadata, build_decorator_metadata_array, build_prop_decorators_metadata, + build_prop_decorators_metadata_in, }; pub use compiler::{ compile_class_metadata, compile_component_class_metadata, diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index a707fc36c..a18ebb0d9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -35,7 +35,7 @@ use crate::ast::expression::{BindingType, ParsedEventType}; use crate::ast::r3::{R3BoundAttribute, R3BoundEvent, SecurityContext}; use crate::class_metadata::{ R3ClassMetadata, R3DeferPerComponentDependency, build_ctor_params_metadata, - build_decorator_metadata_array, build_prop_decorators_metadata, compile_class_metadata, + build_decorator_metadata_array, build_prop_decorators_metadata_in, compile_class_metadata, compile_component_class_metadata, }; use crate::directive::collect_string_consts; @@ -791,11 +791,12 @@ fn build_set_class_metadata_decls<'a>( import_map, Some(source), ), - prop_decorators: build_prop_decorators_metadata( + prop_decorators: build_prop_decorators_metadata_in( &allocator, class, Some(source), namespace_registry, + Some(string_consts), ), }; let metadata_expr = match options.compilation_mode { @@ -1218,6 +1219,7 @@ const ANGULAR_DECORATOR_NAMES: &[&str] = &[ fn extract_all_jit_member_decorators( source: &str, class: &oxc_ast::ast::Class<'_>, + string_consts: &crate::directive::StringConsts<'_>, ) -> (std::vec::Vec, std::vec::Vec) { use oxc_ast::ast::{ClassElement, MethodDefinitionKind, PropertyKey}; @@ -1307,6 +1309,7 @@ fn extract_all_jit_member_decorators( init, &member_name, &explicit_field_decorators, + string_consts, ); angular_decs.extend(synthesized); } @@ -1361,58 +1364,40 @@ enum InitializerApiKind { /// Identify which initializer API a call expression represents. /// -/// Handles three call shapes: -/// - bare identifier: `input(...)`, `output(...)` -/// - `.required` member: `input.required(...)`, `model.required(...)` -/// - namespaced: `core.input(...)`, `core.viewChild.required(...)` -fn classify_initializer_api(callee: &Expression<'_>) -> Option { - fn match_name(name: &str) -> Option { - match name { - "input" => Some(InitializerApiKind::Input), - "output" => Some(InitializerApiKind::Output), - "outputFromObservable" => Some(InitializerApiKind::OutputFromObservable), - "model" => Some(InitializerApiKind::Model), - "viewChild" => Some(InitializerApiKind::ViewChild), - "viewChildren" => Some(InitializerApiKind::ViewChildren), - "contentChild" => Some(InitializerApiKind::ContentChild), - "contentChildren" => Some(InitializerApiKind::ContentChildren), - _ => None, - } - } - fn required_variant(base: InitializerApiKind) -> Option { - match base { - InitializerApiKind::Input => Some(InitializerApiKind::InputRequired), - InitializerApiKind::Model => Some(InitializerApiKind::ModelRequired), - InitializerApiKind::ViewChild => Some(InitializerApiKind::ViewChildRequired), - InitializerApiKind::ContentChild => Some(InitializerApiKind::ContentChildRequired), - _ => None, - } - } - - match callee { - Expression::Identifier(id) => match_name(id.name.as_str()), - Expression::StaticMemberExpression(member) => { - // `.required` — find the underlying base API and promote it. - if member.property.name == "required" { - let base = match &member.object { - Expression::Identifier(id) => match_name(id.name.as_str())?, - Expression::StaticMemberExpression(inner) => { - // Namespaced: `core.input.required` - match_name(inner.property.name.as_str())? - } - _ => return None, - }; - required_variant(base) - } else { - // Namespaced: `core.input(...)`. The outer property *is* the function name. - match &member.object { - Expression::Identifier(_) => match_name(member.property.name.as_str()), - _ => None, - } - } - } - _ => None, - } +/// Like ngtsc's `tryParseInitializerApi`, only Angular's functions count: +/// `input(...)` / `input.required(...)` with `input` imported by name from +/// `@angular/core` (`outputFromObservable` from `@angular/core/rxjs-interop`), +/// under any alias, or `core.input(...)` / `core.input.required(...)` through a +/// namespace import (see [`crate::directive::initializer_api`]). +fn classify_initializer_api( + callee: &Expression<'_>, + string_consts: &crate::directive::StringConsts<'_>, +) -> Option { + use crate::directive::{ + INPUT_API, MODEL_API, OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API, QUERY_APIS, + }; + let apis = [INPUT_API, MODEL_API, OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API] + .into_iter() + .chain(QUERY_APIS) + .collect::>(); + let ((function, _), required) = + crate::directive::initializer_api(callee, Some(string_consts), &apis)?; + Some(match (function, required) { + ("input", false) => InitializerApiKind::Input, + ("input", true) => InitializerApiKind::InputRequired, + ("model", false) => InitializerApiKind::Model, + ("model", true) => InitializerApiKind::ModelRequired, + ("output", false) => InitializerApiKind::Output, + ("outputFromObservable", false) => InitializerApiKind::OutputFromObservable, + ("viewChild", false) => InitializerApiKind::ViewChild, + ("viewChild", true) => InitializerApiKind::ViewChildRequired, + ("viewChildren", false) => InitializerApiKind::ViewChildren, + ("contentChild", false) => InitializerApiKind::ContentChild, + ("contentChild", true) => InitializerApiKind::ContentChildRequired, + ("contentChildren", false) => InitializerApiKind::ContentChildren, + // `output.required()`, `viewChildren.required()`, ... + _ => return None, + }) } /// Namespace alias under which `@angular/core` is imported when JIT synthesis needs @@ -1440,10 +1425,13 @@ fn synthesize_signal_api_decorators( initializer: &Expression<'_>, field_name: &str, existing: &rustc_hash::FxHashSet, + string_consts: &crate::directive::StringConsts<'_>, ) -> std::vec::Vec { let unwrapped = unwrap_jit_initializer(initializer); let Expression::CallExpression(call) = unwrapped else { return std::vec::Vec::new() }; - let Some(kind) = classify_initializer_api(&call.callee) else { return std::vec::Vec::new() }; + let Some(kind) = classify_initializer_api(&call.callee, string_consts) else { + return std::vec::Vec::new(); + }; match kind { InitializerApiKind::Input | InitializerApiKind::InputRequired => { @@ -2150,7 +2138,7 @@ fn transform_angular_file_jit( // Extract Angular and non-Angular member decorators let (member_decorators, non_angular_member_decorators) = - extract_all_jit_member_decorators(source, class); + extract_all_jit_member_decorators(source, class, &string_consts); jit_classes.push(JitClassInfo { class_name, @@ -2779,11 +2767,12 @@ pub fn transform_angular_file( &import_map, Some(source), ), - prop_decorators: build_prop_decorators_metadata( + prop_decorators: build_prop_decorators_metadata_in( &allocator, class, Some(source), &mut file_namespace_registry, + Some(&string_consts), ), }; diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 8a0a7ef7d..d44563967 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -7,9 +7,9 @@ use std::collections::HashMap; use oxc_allocator::{Allocator, Box, Vec}; use oxc_ast::ast::{ - Argument, ArrayExpressionElement, BindingPattern, Class, ClassElement, Declaration, Decorator, - Expression, MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, - Statement, TemplateLiteral, VariableDeclarationKind, + Argument, ArrayExpressionElement, BindingPattern, CallExpression, Class, ClassElement, + Declaration, Decorator, Expression, MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, + Program, PropertyKey, Statement, TemplateLiteral, VariableDeclarationKind, }; use oxc_diagnostics::OxcDiagnostic; use oxc_span::{GetSpan, Span}; @@ -743,14 +743,27 @@ fn scoped_transform_error( /// it was given, because it uses the parameters of the function it's written /// in (see [`scoped_transform_error`]). fn is_out_of_scope(transform: &Prop<'_>, consts: &StringConsts<'_>) -> bool { - transform.expr.is_some() && transform.origin.is_none() && !is_named_function(transform, consts) + transform.expr.is_some() + && transform.origin.is_none() + && reference_identity(transform, consts).is_none() } -/// Whether a transform is emitted as the name of the same-file function it -/// resolves to (see [`transform_expression`]). -fn is_named_function(transform: &Prop<'_>, consts: &StringConsts<'_>) -> bool { - matches!(&transform.value, Value::Reference { name, kind: RefKind::Function(function, _) } - if consts.scope().is_top_level_function(name, function)) +/// The identifier ngtsc emits for a transform that resolves to a reference +/// (the reference's identity in this file, `getIdentityIn`), see +/// [`transform_expression`]: a same-file function's name, or the name an +/// imported function or a global was first reached by, through any variables +/// (`booleanAttribute` for `const t = booleanAttribute` and `transform: t`). +fn reference_identity<'p>(transform: &'p Prop<'_>, consts: &StringConsts<'_>) -> Option<&'p str> { + match &transform.value { + Value::Reference { name, kind: RefKind::Function(function, _) } + if consts.scope().is_top_level_function(name, function) => + { + Some(name) + } + Value::Reference { kind: RefKind::Import { namespace_member: false, local }, .. } => *local, + Value::Reference { name, kind: RefKind::Global } => Some(name), + _ => None, + } } /// Parse `inputs:` / `outputs:` from a decorator metadata object. @@ -938,7 +951,10 @@ fn parse_input_object<'a>( /// The expression ngtsc emits for a transform: a function written in place, or /// the identifier of the declaration it resolved to (`T.f` where -/// `const T = { f }` becomes `f`). For a static method that identifier isn't in +/// `const T = { f }` becomes `f`), or the name an imported function or a +/// global was first reached by (`booleanAttribute` for +/// `const transform = booleanAttribute` and `{ transform }`), see +/// [`reference_identity`]. For a static method that identifier isn't in /// scope, so the written expression is kept there, as it can be written where /// the metadata is compiled (see [`Prop::origin`]): `None` when it can't. pub(crate) fn transform_expression<'a>( @@ -947,11 +963,11 @@ pub(crate) fn transform_expression<'a>( source_text: Option<&'a str>, consts: &StringConsts<'a>, ) -> Option> { - match &transform.value { - Value::Reference { name, .. } if is_named_function(transform, consts) => { + match reference_identity(transform, consts) { + Some(name) => { Some(OutputAstBuilder::variable(allocator, Ident::from(allocator.alloc_str(name)))) } - _ => convert_oxc_expression(allocator, transform.origin?, source_text), + None => convert_oxc_expression(allocator, transform.origin?, source_text), } } @@ -1188,45 +1204,83 @@ pub(crate) const OUTPUT_API: InitializerApi = ("output", "@angular/core"); pub(crate) const OUTPUT_FROM_OBSERVABLE_API: InitializerApi = ("outputFromObservable", "@angular/core/rxjs-interop"); -/// Whether `value` calls one of `apis`, as ngtsc's `tryParseInitializerApi` -/// recognises them: `f()` or `f.required()` with `f` imported by name from the -/// API's module (under any alias), or `ns.f()` or `ns.f.required()` with `ns` -/// a namespace import of it, looking through `as` and parentheses. A function -/// only named like an API (a local one, or one from another module) isn't one. +/// Angular's signal query functions (ngtsc's `QUERY_INITIALIZER_FNS`). +pub(crate) const QUERY_APIS: [InitializerApi; 4] = [ + ("viewChild", "@angular/core"), + ("viewChildren", "@angular/core"), + ("contentChild", "@angular/core"), + ("contentChildren", "@angular/core"), +]; + +/// Whether `value` calls one of `apis` (see [`initializer_api_call`]). pub(crate) fn is_initializer_api_call( value: &Expression<'_>, consts: &StringConsts<'_>, apis: &[InitializerApi], ) -> bool { + initializer_api_call(value, Some(consts), apis).is_some() +} + +/// The call to one of `apis` that `value` is, looking through `as` and +/// parentheses: the API, whether it's the `.required` form, and the call. +/// See [`initializer_api`] for the callees that count. +pub(crate) fn initializer_api_call<'b, 'a>( + value: &'b Expression<'a>, + consts: Option<&StringConsts<'_>>, + apis: &[InitializerApi], +) -> Option<(InitializerApi, bool, &'b CallExpression<'a>)> { let Expression::CallExpression(call) = super::unwrap_initializer_api_expr(value) else { - return false; + return None; }; - let scope = consts.scope(); + let (api, required) = initializer_api(&call.callee, consts, apis)?; + Some((api, required, call)) +} + +/// Which of `apis` `callee` is, and whether it's its `.required` form, as +/// ngtsc's `tryParseInitializerApi` recognises them: `f` or `f.required` with +/// `f` imported by name from the API's module (under any alias), or `ns.f` or +/// `ns.f.required` with `ns` a namespace import of it. A function only named +/// like an API (a local one, one from another module, or an undeclared one) +/// isn't one. +/// +/// Without the file's imports (`consts` is `None`, for the public extraction +/// functions that aren't given them), `f` and `ns.f` are matched by name. +pub(crate) fn initializer_api( + callee: &Expression<'_>, + consts: Option<&StringConsts<'_>>, + apis: &[InitializerApi], +) -> Option<(InitializerApi, bool)> { + let scope = consts.map(StringConsts::scope); // `f`, imported by name. let named = |f: &Expression<'_>| { - let Expression::Identifier(id) = f else { return false }; - scope.import(&id.name).is_some_and(|import| { - apis.iter() - .any(|&(name, module)| import.imported == Some(name) && import.module == module) - }) + let Expression::Identifier(id) = f else { return None }; + let Some(scope) = scope else { + return apis.iter().copied().find(|&(name, _)| id.name == name); + }; + let import = scope.import(&id.name)?; + apis.iter() + .copied() + .find(|&(name, module)| import.imported == Some(name) && import.module == module) }; // `ns.f`, through a namespace import. let namespaced = |f: &Expression<'_>| { - let Expression::StaticMemberExpression(member) = f else { return false }; - let Expression::Identifier(ns) = &member.object else { return false }; - scope.import(&ns.name).is_some_and(|import| { - import.imported.is_none() - && apis - .iter() - .any(|&(name, module)| member.property.name == name && import.module == module) - }) + let Expression::StaticMemberExpression(member) = f else { return None }; + let Expression::Identifier(ns) = &member.object else { return None }; + let function = member.property.name.as_str(); + let Some(scope) = scope else { + return apis.iter().copied().find(|&(name, _)| function == name); + }; + let import = scope.import(&ns.name).filter(|import| import.imported.is_none())?; + apis.iter().copied().find(|&(name, module)| function == name && import.module == module) }; - let callee = &call.callee; - named(callee) - || namespaced(callee) - || matches!(callee, Expression::StaticMemberExpression(required) - if required.property.name == "required" - && (named(&required.object) || namespaced(&required.object))) + if let Some(api) = named(callee).or_else(|| namespaced(callee)) { + return Some((api, false)); + } + let Expression::StaticMemberExpression(required) = callee else { return None }; + if required.property.name != "required" { + return None; + } + named(&required.object).or_else(|| namespaced(&required.object)).map(|api| (api, true)) } /// Extract host metadata from a host object expression. diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 7f95e8a6d..5132c00a5 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -1144,6 +1144,10 @@ pub(crate) enum RefKind<'a> { /// another file. Import { namespace_member: bool, + /// For the binding itself, the local name it was first reached by in + /// this file (`ba` for `import { booleanAttribute as ba }`), which is + /// what ngtsc emits for it (the reference's identity in this file). + local: Option<&'a str>, }, /// An identifier with no declaration in this file that names a standard /// ECMAScript global (see [`ES_GLOBALS`]). @@ -1237,6 +1241,17 @@ impl<'a> Value<'a> { matches!(self, Value::Reference { kind: RefKind::Import { .. }, .. }) } + /// A value computed from this one, when it's an import: it stands for + /// something in another file that has no name here. + fn computed(self) -> Self { + match self { + Value::Reference { name, kind: RefKind::Import { namespace_member, .. } } => { + Value::Reference { name, kind: RefKind::Import { namespace_member, local: None } } + } + value => value, + } + } + /// Whether ngtsc's value is unknown: dynamic, or a global declared outside /// the file, which is only kept as a name so a transform can refer to it. pub(crate) fn is_dynamic(&self) -> bool { @@ -1674,7 +1689,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { if let Some(e) = tpl.expressions.get(i) { match literal(self.eval(e, depth, frame)) { Value::Dynamic => return Value::Dynamic, - value if value.is_import() => return value, + value if value.is_import() => return value.computed(), value => out.push_str(&to_js_string(&value)), } } @@ -1721,6 +1736,13 @@ impl<'s, 'a> Evaluator<'s, 'a> { Some(binding) => binding.value.clone(), None => match self.slot(frame.block, name) { Some(slot) => self.stored(slot, depth), + // An import has no value declaration for + // TypeScript's `getShorthandAssignmentValueSymbol` + // to find (it returns the alias), so ngtsc + // can't evaluate `{ transform }` naming one. + None if self.consts.scope().imports.contains_key(name) => { + Value::Dynamic + } // ngtsc never resolves a shorthand to a global // it can use as a transform. None => match self.eval(&p.value, depth, frame) { @@ -1754,7 +1776,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { Value::Object(inner) if self.spend(inner.len() as u32) => { props.extend(inner); } - value if value.is_import() => return value, + value if value.is_import() => return value.computed(), _ => return Value::Dynamic, } } @@ -1788,7 +1810,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { match self.eval(&m.expression, depth, frame) { Value::String(key) => self.member(object, Key::Str(&key), depth), Value::Number(n) => self.member(object, Key::Num(n), depth), - key if key.is_import() => key, + key if key.is_import() => key.computed(), _ => Value::Dynamic, } } @@ -1802,7 +1824,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { ) -> Value<'a> { match self.eval(&c.test, depth, frame) { test if test.is_dynamic() => Value::Dynamic, - test if test.is_import() => test, + test if test.is_import() => test.computed(), test if test.truthy() => self.eval(&c.consequent, depth, frame), _ => self.eval(&c.alternate, depth, frame), } @@ -1821,7 +1843,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { } match self.eval(&u.argument, depth, frame) { value if value.is_dynamic() => Value::Dynamic, - value if value.is_import() => value, + value if value.is_import() => value.computed(), value => unary(u.operator, &value), } } @@ -1836,7 +1858,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { let right = literal(self.eval(&b.right, depth, frame)); match (left, right) { (Value::Dynamic, _) | (_, Value::Dynamic) => Value::Dynamic, - (value, _) | (_, value) if value.is_import() => value, + (value, _) | (_, value) if value.is_import() => value.computed(), (left, right) => binary(b.operator, &left, &right), } } @@ -1860,7 +1882,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { let right = self.eval(&l.right, depth, frame); match (left, right) { (left, right) if left.is_dynamic() || right.is_dynamic() => Value::Dynamic, - (left, _) if left.is_import() => left, + (left, _) if left.is_import() => left.computed(), (left, right) => match (l.operator, left.truthy()) { (LogicalOperator::And, true) | (LogicalOperator::Or, false) => right, _ => left, @@ -1877,7 +1899,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { ) -> std::vec::Vec> { match self.eval(expr, depth, frame) { Value::Array(inner) if self.spend(inner.len() as u32) => inner, - value if value.is_import() => vec![value], + value if value.is_import() => vec![value.computed()], // ngtsc marks only this element as dynamic, not the whole array. _ => vec![Value::Dynamic], } @@ -1926,10 +1948,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { if let Some(import) = scope.imports.get(name) { return match import.imported { Some(imported) => { - let name = if imported == "default" { name } else { imported }; + let declared = if imported == "default" { name } else { imported }; Value::Reference { - name: name.into(), - kind: RefKind::Import { namespace_member: false }, + name: declared.into(), + kind: RefKind::Import { namespace_member: false, local: Some(name) }, } } None => Value::Module, @@ -2246,7 +2268,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { let imported = matches!(left, TSTypeName::IdentifierReference(id) if self.consts.scope().imports.contains_key(id.name.as_str())); let kind = if imported { - RefKind::Import { namespace_member: false } + RefKind::Import { namespace_member: false, local: None } } else { RefKind::Other }; @@ -2305,7 +2327,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { (function, overloads) } // An imported function runs in another file. - callee if callee.is_import() => return callee, + callee if callee.is_import() => return callee.computed(), _ => return Value::Dynamic, }; // A body-less first declaration (an overload, `declare function`) can't be evaluated. @@ -2426,7 +2448,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { | Value::Bool(_) | Value::Number(_) | Value::String(_) => s.push_str(&to_js_string(&arg)), - arg if arg.is_import() => return arg, + arg if arg.is_import() => return arg.computed(), _ => return Value::Dynamic, } } @@ -2471,13 +2493,13 @@ impl<'s, 'a> Evaluator<'s, 'a> { } Value::Module => Value::Reference { name: key_str(), - kind: RefKind::Import { namespace_member: true }, + kind: RefKind::Import { namespace_member: true, local: None }, }, Value::Reference { kind: RefKind::Class(class), .. } => { self.static_member(class, &key_str(), depth) } // The object is in another file, and so is its member. - object @ Value::Reference { kind: RefKind::Import { .. }, .. } => object, + object @ Value::Reference { kind: RefKind::Import { .. }, .. } => object.computed(), // Including a member of a global (`Math.round`): ngtsc only // resolves the global's own declaration. _ => Value::Dynamic, @@ -2590,11 +2612,11 @@ pub(crate) fn transform_error<'a>( // An imported function can't be inspected from this file, so whether // it's generic or overloaded is unknown; the name clash is checked // after those. - Value::Reference { kind: RefKind::Import { namespace_member: false }, .. } => { + Value::Reference { kind: RefKind::Import { namespace_member: false, .. }, .. } => { return clash(); } // ngtsc can't name `ns.f` in the compiled file. - Value::Reference { kind: RefKind::Import { namespace_member: true }, .. } => { + Value::Reference { kind: RefKind::Import { namespace_member: true, .. }, .. } => { return clash().or_else(|| { Some((format!("Input transform function could not be referenced{suffix}"), node())) }); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 4cd74b510..957f050f6 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -27,6 +27,10 @@ pub use compiler::{ create_inputs_literal, create_outputs_literal, }; pub(crate) use decorator::find_directive_decorator; +pub(crate) use decorator::{ + INPUT_API, MODEL_API, OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API, QUERY_APIS, initializer_api, + initializer_api_call, +}; pub use decorator::{ StringConsts, collect_string_consts, decorator_io_errors, extract_directive_metadata, find_directive_decorator_span, diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 1b78099d4..3615d2b86 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -110,14 +110,6 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { } } -/// Angular's signal query functions (ngtsc's `QUERY_INITIALIZER_FNS`). -const QUERY_APIS: [super::decorator::InitializerApi; 4] = [ - ("viewChild", "@angular/core"), - ("viewChildren", "@angular/core"), - ("contentChild", "@angular/core"), - ("contentChildren", "@angular/core"), -]; - /// Try to unwrap a forwardRef call and extract the inner expression. /// /// Mirrors ngtsc's `tryUnwrapForwardRef`: `forwardRef(() => X)`, @@ -268,78 +260,22 @@ pub(crate) struct ModelMapping<'a> { /// readonly aliased = model(undefined, { alias: 'myAlias' }); // input alias 'myAlias', output 'myAliasChange' /// ``` /// +/// Like ngtsc, only Angular's `model` counts, when `consts` holds the file's +/// imports (see [`super::decorator::initializer_api`]). +/// /// Based on Angular's `model_function.ts` in the compiler-cli. pub(crate) fn try_parse_signal_model<'a>( allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, + consts: Option<&super::StringConsts<'_>>, ) -> Option> { - // Check if the value is a call expression (unwrapping `as`/parenthesized). - let call_expr = match unwrap_initializer_api_expr(value) { - Expression::CallExpression(call) => call, - _ => return None, - }; - - // Determine if this is model() or model.required() - let is_required = match &call_expr.callee { - // model() - simple identifier call - Expression::Identifier(id) if id.name == "model" => false, - // model.required() - member expression call - Expression::StaticMemberExpression(member) => { - // Check for model.required / core.model.required - if member.property.name == "required" { - if is_initializer_fn_reference(&member.object, "model") { - true - } else { - return None; - } - } else if member.property.name == "model" { - // Handle namespaced calls like `core.model()` - if let Expression::Identifier(_) = &member.object { - let output_binding = Ident::from( - allocator.alloc_str(&format!("{}Change", property_name.as_str())), - ); - return Some(ModelMapping { - input: R3InputMetadata { - class_property_name: property_name.clone(), - binding_property_name: property_name.clone(), - required: false, - is_signal: true, - transform_function: None, - }, - output: (property_name, output_binding), - }); - } - return None; - } else { - return None; - } - } - _ => return None, - }; + let (_, is_required, call_expr) = + super::decorator::initializer_api_call(value, consts, &[super::decorator::MODEL_API])?; - // Parse options from arguments - // For model(): first arg is initial value, second arg is options - // For model.required(): first arg is options + // model(initialValue, options?) / model.required(options?) let options_arg_index = if is_required { 0 } else { 1 }; - - let mut alias: Option> = None; - - if let Some(options_arg) = call_expr.arguments.get(options_arg_index) { - if let Argument::ObjectExpression(obj) = options_arg { - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { - continue; - }; - - if key_name.as_str() == "alias" { - alias = extract_string_value(&prop.value); - } - } - } - } - } + let alias = options_alias(call_expr.arguments.get(options_arg_index)); let binding_property_name = alias.unwrap_or_else(|| property_name.clone()); // Output binding name is always `bindingPropertyName + "Change"` @@ -358,6 +294,20 @@ pub(crate) fn try_parse_signal_model<'a>( }) } +/// The string `alias` of an initializer API's options object literal. +fn options_alias<'a>(options: Option<&Argument<'a>>) -> Option> { + let Some(Argument::ObjectExpression(obj)) = options else { return None }; + let mut alias = None; + for prop in &obj.properties { + if let ObjectPropertyKind::ObjectProperty(prop) = prop + && get_property_key_name(&prop.key).is_some_and(|key| key == "alias") + { + alias = extract_string_value(&prop.value); + } + } + alias +} + /// Try to detect and parse a signal-based output from a property initializer. /// /// Signal-based outputs are created by calling `output()` or `output()` from @@ -368,58 +318,46 @@ pub(crate) fn try_parse_signal_model<'a>( /// For `outputFromObservable(observable, options?)` the options are the second argument; /// the observable expression is irrelevant for metadata extraction. /// +/// Like ngtsc, only Angular's functions count, when `consts` holds the file's +/// imports (see [`super::decorator::initializer_api`]). +/// /// Based on Angular's `output_function.ts` in the compiler-cli. pub(crate) fn try_parse_signal_output<'a>( value: &Expression<'a>, property_name: Ident<'a>, + consts: Option<&super::StringConsts<'_>>, ) -> Option<(Ident<'a>, Ident<'a>)> { - let call_expr = match unwrap_initializer_api_expr(value) { - Expression::CallExpression(call) => call, - _ => return None, - }; - - // Detect which output initializer is called and whether options are at index 0 or 1. - let is_from_observable = match &call_expr.callee { - Expression::Identifier(id) => match id.name.as_str() { - "output" => false, - "outputFromObservable" => true, - _ => return None, - }, - // Handle namespaced calls like `core.output()` or `rxjs.outputFromObservable()` - Expression::StaticMemberExpression(member) => { - if !matches!(&member.object, Expression::Identifier(_)) { - return None; - } - match member.property.name.as_str() { - "output" => false, - "outputFromObservable" => true, - _ => return None, - } - } - _ => return None, - }; + use super::decorator::{OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API}; + let (api, is_required, call_expr) = super::decorator::initializer_api_call( + value, + consts, + &[OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API], + )?; + // ngtsc rejects `output.required()`; it isn't an output. + if is_required { + return None; + } // output() → options at index 0; outputFromObservable(obs, options?) → options at index 1 - let options_idx = if is_from_observable { 1 } else { 0 }; - let mut alias: Option> = None; - - if let Some(Argument::ObjectExpression(obj)) = call_expr.arguments.get(options_idx) { - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { - continue; - }; - if key_name.as_str() == "alias" { - alias = extract_string_value(&prop.value); - } - } - } - } + let options_idx = if api == OUTPUT_FROM_OBSERVABLE_API { 1 } else { 0 }; + let alias = options_alias(call_expr.arguments.get(options_idx)); let binding_property_name = alias.unwrap_or_else(|| property_name.clone()); Some((property_name, binding_property_name)) } +/// Unwrap `as`/`satisfies`/parenthesized wrappers around an initializer-API call +/// expression, matching ngc's `tryParseInitializerApi` which recurses through +/// `isAsExpression` and `isParenthesizedExpression` (e.g. `x = input(0) as any`, +/// `x = (input(0))`). +pub(crate) fn unwrap_initializer_api_expr<'a, 'b>(expr: &'b Expression<'a>) -> &'b Expression<'a> { + match expr { + Expression::TSAsExpression(e) => unwrap_initializer_api_expr(&e.expression), + Expression::ParenthesizedExpression(e) => unwrap_initializer_api_expr(&e.expression), + _ => expr, + } +} + /// Try to detect and parse a signal-based input from a property initializer. /// /// Signal-based inputs are created by calling functions like `input()` or `input.required()` @@ -433,101 +371,21 @@ pub(crate) fn try_parse_signal_output<'a>( /// readonly aliasedInput = input({ alias: 'myAlias' }); /// ``` /// +/// Like ngtsc, only Angular's `input` counts, when `consts` holds the file's +/// imports (see [`super::decorator::initializer_api`]). +/// /// Based on Angular's `input_function.ts` in the compiler-cli. -/// Unwrap `as`/`satisfies`/parenthesized wrappers around an initializer-API call -/// expression, matching ngc's `tryParseInitializerApi` which recurses through -/// `isAsExpression` and `isParenthesizedExpression` (e.g. `x = input(0) as any`, -/// `x = (input(0))`). -pub(crate) fn unwrap_initializer_api_expr<'a, 'b>(expr: &'b Expression<'a>) -> &'b Expression<'a> { - match expr { - Expression::TSAsExpression(e) => unwrap_initializer_api_expr(&e.expression), - Expression::ParenthesizedExpression(e) => unwrap_initializer_api_expr(&e.expression), - _ => expr, - } -} - -/// Returns `true` when `object` is the `input`/`model`/etc. reference for a -/// namespaced call, i.e. either a bare `` identifier or `.` member -/// access. Used to recognize `.required()` and `core..required()`. -fn is_initializer_fn_reference(object: &Expression<'_>, function_name: &str) -> bool { - match object { - Expression::Identifier(id) => id.name == function_name, - Expression::StaticMemberExpression(member) => { - member.property.name == function_name - && matches!(&member.object, Expression::Identifier(_)) - } - _ => false, - } -} - pub(crate) fn try_parse_signal_input<'a>( - _allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, + consts: Option<&super::StringConsts<'_>>, ) -> Option> { - // Check if the value is a call expression (unwrapping `as`/parenthesized). - let call_expr = match unwrap_initializer_api_expr(value) { - Expression::CallExpression(call) => call, - _ => return None, - }; - - // Determine if this is input() or input.required() - let is_required = match &call_expr.callee { - // input() - simple identifier call - Expression::Identifier(id) if id.name == "input" => false, - // input.required() - member expression call - Expression::StaticMemberExpression(member) => { - // Check for input.required / core.input.required - if member.property.name == "required" { - if is_initializer_fn_reference(&member.object, "input") { - true - } else { - return None; - } - } else if member.property.name == "input" { - // Handle namespaced calls like `core.input()` - if let Expression::Identifier(_) = &member.object { - return Some(R3InputMetadata { - class_property_name: property_name.clone(), - binding_property_name: property_name, - required: false, - is_signal: true, - transform_function: None, - }); - } - return None; - } else { - return None; - } - } - _ => return None, - }; + let (_, is_required, call_expr) = + super::decorator::initializer_api_call(value, consts, &[super::decorator::INPUT_API])?; - // Parse options from arguments - // For input(): first arg is initial value, second arg is options - // For input.required(): first arg is options + // input(initialValue, options?) / input.required(options?) let options_arg_index = if is_required { 0 } else { 1 }; - - let mut alias: Option> = None; - - if let Some(options_arg) = call_expr.arguments.get(options_arg_index) { - if let Argument::ObjectExpression(obj) = options_arg { - for prop in &obj.properties { - if let ObjectPropertyKind::ObjectProperty(prop) = prop { - let Some(key_name) = get_property_key_name(&prop.key) else { - continue; - }; - - if key_name.as_str() == "alias" { - alias = extract_string_value(&prop.value); - } - // Note: Signal inputs don't support transform in the same way as decorator inputs. - // The transform is captured in the signal initializer itself. - } - } - } - } - + let alias = options_alias(call_expr.arguments.get(options_arg_index)); let binding_property_name = alias.unwrap_or_else(|| property_name.clone()); Some(R3InputMetadata { @@ -535,7 +393,8 @@ pub(crate) fn try_parse_signal_input<'a>( binding_property_name, required: is_required, is_signal: true, - transform_function: None, // Signal inputs don't capture transform metadata + // Signal inputs capture their transform in the signal initializer itself. + transform_function: None, }) } @@ -562,7 +421,10 @@ pub fn extract_input_metadata<'a>( /// [`extract_input_metadata`] for a class in the file `consts` was collected /// from, which also recognises `@core.Input()` through a namespace import of -/// `@angular/core` (see [`is_core_namespace`]). +/// `@angular/core` (see [`is_core_namespace`]). With `consts`, a signal member +/// counts only when it calls Angular's `input()` / `model()`, as ngtsc's +/// `tryParseInitializerApi` sees it (see [`super::decorator::initializer_api`]); +/// without, any function with that name does. pub fn extract_input_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -598,13 +460,13 @@ pub fn extract_input_metadata_in<'a>( if let Some(property_name) = get_property_key_name(&prop.key) { // Check for model() first since it also creates an input if let Some(model_mapping) = - try_parse_signal_model(allocator, value, property_name) + try_parse_signal_model(allocator, value, property_name.clone(), consts) { inputs.push(model_mapping.input); } // Then check for input() else if let Some(signal_input) = - try_parse_signal_input(allocator, value, property_name) + try_parse_signal_input(value, property_name, consts) { inputs.push(signal_input); } @@ -727,7 +589,10 @@ pub fn extract_output_metadata<'a>( /// [`extract_output_metadata`] for a class in the file `consts` was collected /// from, which also recognises `@core.Output()` through a namespace import of -/// `@angular/core` (see [`is_core_namespace`]). +/// `@angular/core` (see [`is_core_namespace`]). With `consts`, a signal member +/// counts only when it calls Angular's `output()` / `outputFromObservable()` / +/// `model()` (see [`super::decorator::initializer_api`]); without, any +/// function with that name does. pub fn extract_output_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -757,13 +622,13 @@ pub fn extract_output_metadata_in<'a>( if let Some(property_name) = get_property_key_name(&prop.key) { // Check for output() signal first if let Some(output_mapping) = - try_parse_signal_output(value, property_name.clone()) + try_parse_signal_output(value, property_name.clone(), consts) { outputs.push(output_mapping); } // Then check for model() signal which also creates an output else if let Some(model_mapping) = - try_parse_signal_model(allocator, value, property_name) + try_parse_signal_model(allocator, value, property_name, consts) { outputs.push(model_mapping.output); } @@ -801,10 +666,6 @@ pub fn extract_output_metadata_in<'a>( // and Signal-based Query Detection (viewChild(), viewChildren(), contentChild(), contentChildren()) // ============================================================================ -/// Signal query function names. -/// These are imported from @angular/core and used to create signal-based queries. -const SIGNAL_QUERY_FNS: &[&str] = &["viewChild", "viewChildren", "contentChild", "contentChildren"]; - /// Parsed query decorator configuration. struct QueryConfig<'a> { /// The query predicate (type or string selectors). @@ -985,6 +846,9 @@ impl SignalQueryType { /// readonly requiredPanel = contentChild.required('panel'); /// readonly tabs = contentChildren(TabComponent, { descendants: true }); /// ``` +/// +/// Like ngtsc, only Angular's functions count, when `consts` holds the file's +/// imports (see [`super::decorator::initializer_api`]). fn try_parse_signal_query<'a>( allocator: &'a Allocator, value: &'a Expression<'a>, @@ -992,64 +856,13 @@ fn try_parse_signal_query<'a>( source_text: Option<&'a str>, consts: Option<&super::StringConsts<'a>>, ) -> Option<(SignalQueryType, R3QueryMetadata<'a>)> { - // Check if the value is a call expression (unwrapping `as`/parenthesized). - let call_expr = match unwrap_initializer_api_expr(value) { - Expression::CallExpression(call) => call, - _ => return None, - }; - - // Helper to get query type from function name - let get_query_type = |name: &str| -> Option { - match name { - "viewChild" => Some(SignalQueryType::ViewChild), - "viewChildren" => Some(SignalQueryType::ViewChildren), - "contentChild" => Some(SignalQueryType::ContentChild), - "contentChildren" => Some(SignalQueryType::ContentChildren), - _ => None, - } - }; - - // Check if the callee is one of the signal query functions - // Handles three patterns: - // 1. Direct call: viewChild(), viewChildren(), contentChild(), contentChildren() - // 2. Required call: viewChild.required(), contentChild.required() - // 3. Namespaced call: core.viewChild(), core.viewChild.required() - let query_type = match &call_expr.callee { - // Pattern 1: Direct call - viewChild(), viewChildren(), etc. - Expression::Identifier(id) => get_query_type(id.name.as_str())?, - Expression::StaticMemberExpression(member) => { - // Pattern 2: Required call - viewChild.required(), contentChild.required() - if member.property.name == "required" { - match &member.object { - // viewChild.required() - Expression::Identifier(id) => get_query_type(id.name.as_str())?, - // Pattern 3b: Namespaced required call - core.viewChild.required() - // member.object is `core.viewChild` (a StaticMemberExpression) - Expression::StaticMemberExpression(inner_member) => { - // inner_member.object should be an identifier (namespace) - // inner_member.property should be the query function name - if let Expression::Identifier(_) = &inner_member.object { - get_query_type(inner_member.property.name.as_str())? - } else { - return None; - } - } - _ => return None, - } - } - // Pattern 3a: Namespaced call - core.viewChild() - else if SIGNAL_QUERY_FNS.contains(&member.property.name.as_str()) { - // Must be namespace.queryFn() pattern - if let Expression::Identifier(_) = &member.object { - get_query_type(member.property.name.as_str())? - } else { - return None; - } - } else { - return None; - } - } - _ => return None, + let ((function, _), _, call_expr) = + super::decorator::initializer_api_call(value, consts, &super::decorator::QUERY_APIS)?; + let query_type = match function { + "viewChild" => SignalQueryType::ViewChild, + "viewChildren" => SignalQueryType::ViewChildren, + "contentChild" => SignalQueryType::ContentChild, + _ => SignalQueryType::ContentChildren, }; // Parse the predicate from the first argument @@ -1138,7 +951,10 @@ pub fn extract_view_queries<'a>( } /// [`extract_view_queries`], resolving predicates that reference same-file -/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does. +/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does, and +/// recognising only Angular's `viewChild()` / `viewChildren()` (see +/// [`super::decorator::initializer_api`]); without `consts`, any function with +/// that name. pub(crate) fn extract_view_queries_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -1320,7 +1136,10 @@ pub fn extract_content_queries<'a>( } /// [`extract_content_queries`], resolving predicates that reference same-file -/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does. +/// consts (`@ViewChild(SELECTOR)`) the way ngtsc's partial evaluator does, and +/// recognising only Angular's `contentChild()` / `contentChildren()` (see +/// [`super::decorator::initializer_api`]); without `consts`, any function with +/// that name. pub(crate) fn extract_content_queries_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -1839,8 +1658,12 @@ pub(crate) fn parse_decorator_queries<'a>( .filter_map(|element| { let ClassElement::PropertyDefinition(prop) = element else { return None }; let name = get_property_key_name(&prop.key)?; - super::decorator::is_initializer_api_call(prop.value.as_ref()?, consts, &QUERY_APIS) - .then_some(name) + super::decorator::is_initializer_api_call( + prop.value.as_ref()?, + consts, + &super::decorator::QUERY_APIS, + ) + .then_some(name) }) .collect(); // ngtsc checks the content queries first, and reports the `new` expression. diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 4e9375622..991f9f01e 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -154,9 +154,9 @@ pub use class_debug_info::{R3ClassDebugInfo, compile_class_debug_info}; // Re-export class metadata types pub use class_metadata::{ R3ClassMetadata, R3DeferPerComponentDependency, build_ctor_params_metadata, - build_decorator_metadata_array, build_prop_decorators_metadata, compile_class_metadata, - compile_component_class_metadata, compile_component_metadata_async_resolver, - compile_opaque_async_class_metadata, + build_decorator_metadata_array, build_prop_decorators_metadata, + build_prop_decorators_metadata_in, compile_class_metadata, compile_component_class_metadata, + compile_component_metadata_async_resolver, compile_opaque_async_class_metadata, }; // Re-export dts types diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 91101319e..2bef921b7 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 794, "fixtures compared"); + assert_eq!(compared, 872, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -383,6 +383,27 @@ export class Dir {} let result = transform(source); assert!(errors(&result, source).is_empty(), "{:?}", errors(&result, source)); assert!(strip(&result.code).contains(r#"inputs:{x:[2,"x","x",fn]}"#), "{}", result.code); + + // A value computed from an import (a call, a member, a condition on one) + // isn't the import: it has no name in this file, so it's kept as written, + // never replaced by the import's name. + for (transform_expr, emitted) in + [("fn()", "fn()"), ("fn.f", "fn.f"), ("fn ? a : a", "fn?a:a"), ("SHARED || a", "SHARED||a")] + { + let source = format!( + "import {{Directive}} from '@angular/core'; +import {{fn, SHARED}} from './shared'; +export function a(v: string) {{ return 1; }} +@Directive({{selector: '[d]', inputs: [{{name: 'x', transform: {transform_expr}}}]}}) +export class Dir {{}} +" + ); + let result = transform(&source); + let code = strip(&result.code); + let bare = format!(r#"inputs:{{x:[2,"x","x",{emitted}]}}"#); + let wrapped = format!(r#"inputs:{{x:[2,"x","x",({emitted})]}}"#); + assert!(code.contains(&bare) || code.contains(&wrapped), "{code}"); + } } /// The same for an `@Input(...)` argument: ngtsc 22.1.7 reads these from @@ -441,13 +462,13 @@ export class Dir { /// A transform returned by a function the metadata calls is emitted where the /// directive is compiled, outside that function. A parameter becomes the -/// argument it was passed (the snapshot's `scope-*` probes); anything else that -/// uses the function's parameters would mean something else there, or nothing. -/// ngtsc 22.1.7 emits `(v) => v + name` as written (`name` is then -/// `window.name`) and `booleanAttribute` for `o.t` (the name the argument was -/// first given); oxc reports these instead. (`this` and `arguments` there -/// aren't analyzable, so ngtsc rejects those itself: `scope-this`, -/// `scope-arguments`.) +/// argument it was passed (the snapshot's `scope-*` probes), and a reference +/// read through one the name it was first given (`booleanAttribute` for `o.t`, +/// `probe: shorthandRef-helperParamMember`); anything else that uses the +/// function's parameters would mean something else there, or nothing. ngtsc +/// 22.1.7 emits `(v) => v + name` as written (`name` is then `window.name`); +/// oxc reports these instead. (`this` and `arguments` there aren't analyzable, +/// so ngtsc rejects those itself: `scope-this`, `scope-arguments`.) #[test] fn transforms_using_the_parameters_of_a_called_function_are_reported() { let cases = [ @@ -457,12 +478,6 @@ fn transforms_using_the_parameters_of_a_called_function_are_reported() { "@Directive.inputs", "make('x')", ), - ( - "function make(o: any) { return [{ name: 'x', transform: o.t }]; }", - "inputs: make({ t: booleanAttribute })", - "@Directive.inputs", - "make({ t: booleanAttribute })", - ), ( "function opts(n: string) { return { transform: (v: string) => v + n }; }", "", @@ -644,9 +659,8 @@ export class Dir { a: any; b: any; c: any; } /// an error, since `input` here isn't Angular's. The snapshot's /// `initializerApi-*` probes cover the ones ngtsc rejects, and the outputs. /// -/// oxc still compiles such a member as a signal input (it recognises signal -/// members by name), where ngtsc makes it a plain one: that's why these compare -/// only the diagnostics. +/// Such a member is a plain property, so `value` is the metadata's plain input +/// (the snapshot's `signal-*Listed` probes compare the same with ngtsc). #[test] fn only_angular_initializer_apis_collide_with_metadata() { let cases = [ @@ -665,15 +679,16 @@ export class Dir {{ value = {initializer}; }} ); let result = transform(&source); assert_eq!(errors(&result, &source), vec![], "{import} {initializer}"); + let code = strip(&result.code); + assert!(code.contains(r#"inputs:{value:"value"}"#), "{import} {initializer}\n{code}"); } } /// The same for a signal query also declared in `queries:`: ngtsc 22.1.7 /// reports it only for Angular's `viewChild()`, `contentChild()`, ... (the /// snapshot's `initializerApi-viewChild*` probes), and compiles these, whose -/// functions aren't Angular's. oxc still compiles such a member as a signal -/// query too (it recognises them by name), so these compare only the -/// diagnostics. +/// functions aren't Angular's: the member is a plain property, and only the +/// decorator's query is compiled. #[test] fn only_angular_signal_queries_collide_with_decorator_queries() { let cases = [ @@ -695,6 +710,11 @@ export class Dir {{ foo = {initializer}; }} ); let result = transform(&source); assert_eq!(errors(&result, &source), vec![], "{declaration} {initializer}"); + assert!( + !result.code.contains("QuerySignal"), + "{declaration} {initializer}\n{}", + result.code + ); } } diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 7c64a5630..f8ff5e799 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -8198,6 +8198,1283 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: signal-named", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n req = input.required();\n al = input(0, {alias: 'a'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],req:[1,\"req\"],al:[1,\"a\",\"al\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-aliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input as inp} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = inp(0);\n req = inp.required({alias: 'r'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],req:[1,\"r\",\"req\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-namespace", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Directive({selector: '[d]'})\nexport class Dir {\n value = ng.input(0);\n req = ng.input.required();\n m = ng.model(0);\n mr = ng.model.required();\n o = ng.output();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],req:[1,\"req\"],m:[1,\"m\"],mr:[1,\"mr\"]}", + "outputs": "{m:\"mChange\",mr:\"mrChange\",o:\"o\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-namespaceAndNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input} from '@angular/core';\nimport * as ng from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = ng.input(0);\n other = input(1);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],other:[1,\"other\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-modelAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive, model as m} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = m(0);\n req = m.required({alias: 'r'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],req:[1,\"r\",\"req\"]}", + "outputs": "{value:\"valueChange\",req:\"rChange\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-outputAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive, output as out} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = out();\n al = out({alias: 'a'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{changed:\"changed\",al:\"a\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-queriesAliased", + "origin": "probe", + "files": { + "test.ts": "import {Component, viewChild as vc, contentChild as cc, viewChildren as vcs, contentChildren as ccs} from '@angular/core';\n@Component({selector: 'c', template: '
'})\nexport class Dir {\n a = vc('ref');\n b = vc.required('ref');\n c = cc('ref');\n d = vcs('ref');\n e = ccs('ref', {descendants: true});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuerySignal(dirIndex,ctx.c,[\"ref\"],5)(dirIndex,ctx.e,[\"ref\"],5);}if(rf&2){i0.ɵɵqueryAdvance(2);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuerySignal(ctx.a,[\"ref\"],5)(ctx.b,[\"ref\"],5)(ctx.d,[\"ref\"],5);}if(rf&2){i0.ɵɵqueryAdvance(3);}}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-queriesNamespace", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Component({selector: 'c', template: '
'})\nexport class Dir {\n a = ng.viewChild('ref');\n b = ng.viewChild.required('ref');\n c = ng.contentChildren('ref');\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuerySignal(dirIndex,ctx.c,[\"ref\"],4);}if(rf&2){i0.ɵɵqueryAdvance();}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuerySignal(ctx.a,[\"ref\"],5)(ctx.b,[\"ref\"],5);}if(rf&2){i0.ɵɵqueryAdvance(2);}}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-fromObservableNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {outputFromObservable} from '@angular/core/rxjs-interop';\nimport {Subject} from 'rxjs';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = outputFromObservable(new Subject());\n al = outputFromObservable(new Subject(), {alias: 'a'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{changed:\"changed\",al:\"a\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-fromObservableAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {outputFromObservable as ofo} from '@angular/core/rxjs-interop';\nimport {Subject} from 'rxjs';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = ofo(new Subject());\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{changed:\"changed\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-fromObservableNamespace", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport * as rx from '@angular/core/rxjs-interop';\nimport {Subject} from 'rxjs';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = rx.outputFromObservable(new Subject());\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{changed:\"changed\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-asAndParens", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = (input(0)) as any;\n req = (input.required() as any);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"],req:[1,\"req\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-undeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n req = input.required();\n changed = output();\n m = model(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-undeclaredListed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\n@Directive({selector: '[d]', inputs: ['value']})\nexport class Dir {\n value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-undeclaredOutputListed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\n@Directive({selector: '[d]', outputs: ['changed']})\nexport class Dir {\n changed = output();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{changed:\"changed\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-undeclaredQueries", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\n@Component({selector: 'c', template: '
'})\nexport class Dir {\n a = viewChild('ref');\n b = contentChildren('ref');\n c = viewChild.required('ref');\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-localFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nfunction input(v?: any, o?: any): any { return v; }\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-localFunctionListed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nfunction input(v?: any, o?: any): any { return v; }\n@Directive({selector: '[d]', inputs: ['value']})\nexport class Dir {\n value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-localObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nconst local = { input: (v?: any, o?: any): any => v, output: (o?: any): any => null, model: (v?: any): any => v, viewChild: (p: any): any => null };\n@Directive({selector: '[d]'})\nexport class Dir {\n value = local.input(0);\n changed = local.output();\n m = local.model(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-localObjectListed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nconst local = { input: (v?: any, o?: any): any => v, output: (o?: any): any => null, model: (v?: any): any => v, viewChild: (p: any): any => null };\n@Directive({selector: '[d]', inputs: ['value']})\nexport class Dir {\n value = local.input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-localObjectQuery", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\nconst local = { input: (v?: any, o?: any): any => v, output: (o?: any): any => null, model: (v?: any): any => v, viewChild: (p: any): any => null };\n@Component({selector: 'c', template: '
'})\nexport class Dir {\n a = local.viewChild('ref');\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleOutputFromObservable", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {outputFromObservable} from './other';\nimport {Subject} from 'rxjs';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = outputFromObservable(new Subject());\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-callParenthesized", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = (input)(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-elementAccess", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Directive({selector: '[d]'})\nexport class Dir {\n value = ng['input'](0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-coreNamedWrongName", + "origin": "probe", + "files": { + "test.ts": "import {Directive, model as input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:[1,\"value\"]}", + "outputs": "{value:\"valueChange\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {input, output, model} from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n req = input.required();\n changed = output();\n m = model(0);\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleNamedListed", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {input} from './other';\n@Directive({selector: '[d]', inputs: ['value']})\nexport class Dir {\n value = input(0);\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleNamespace", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport * as o from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = o.input(0);\n req = o.input.required();\n changed = o.output();\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleQueries", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {viewChild, contentChildren} from './other';\n@Component({selector: 'c', template: '
'})\nexport class Dir {\n a = viewChild('ref');\n b = viewChild.required('ref');\n c = contentChildren('ref');\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-otherModuleFromObservableAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {outputFromObservable as ofo} from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n changed = ofo(null);\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-mixed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, input, output} from '@angular/core';\nimport * as o from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n real = input(0);\n fake = o.input(0);\n realOut = output();\n fakeOut = o.output();\n}\n", + "other.ts": "export function input(v?: any, o?: any): any { return v; }\nexport namespace input { export function required(o?: any): any { return null; } }\nexport function output(o?: any): any { return null; }\nexport function model(v?: any, o?: any): any { return v; }\nexport namespace model { export function required(o?: any): any { return null; } }\nexport function viewChild(p: any, o?: any): any { return null; }\nexport namespace viewChild { export function required(p: any, o?: any): any { return null; } }\nexport function contentChildren(p: any, o?: any): any { return null; }\nexport function outputFromObservable(s: any, o?: any): any { return null; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{real:[1,\"real\"]}", + "outputs": "{realOut:\"realOut\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signal-defaultImport", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport input from './other-default';\n@Directive({selector: '[d]'})\nexport class Dir {\n value = input(0);\n}\n", + "other-default.ts": "export default function input(v?: any): any { return v; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedConstDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedConstNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst t = booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: t}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedConstNamedSame", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedLet", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nlet transform = numberAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",numberAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedExported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nexport const transform = booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedTyped", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform: (v: unknown) => boolean = booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedDestructObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst {transform} = {transform: booleanAttribute};\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedDestructArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst [transform] = [booleanAttribute];\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedAs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute as any;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedParens", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = (booleanAttribute);\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-chain", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst t = booleanAttribute;\nconst transform = t;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-chainDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst t = booleanAttribute;\nconst transform = t;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction toNum(v: string) { return 1; }\nconst transform = toNum;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localFnDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction toNum(v: string) { return 1; }\nconst transform = toNum;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localExportedFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nexport function toNum(v: string) { return 1; }\nconst transform = toNum;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localFnDeclShorthand", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction transform(v: string) { return 1; }\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",transform]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localFnDeclShorthandDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction transform(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",transform]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-staticMethod", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nclass U { static c(v: string) { return 1; } }\nconst transform = U.c;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",c]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits the static method's bare name (`c`), which isn't in scope where the metadata is compiled; oxc keeps `transform` (see `static_method_transform_is_not_confused_with_a_same_named_function`)" + }, + { + "name": "probe: shorthandRef-localObjMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst ns = {fn: (v: string) => 1};\nconst transform = ns.fn;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[136, 141]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localObjFnMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction toNum(v: string) { return 1; }\nconst ns = {fn: toNum};\nconst transform = ns.fn;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-localConstArrow", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst toNum = (v: string) => 1;\nconst transform = toNum;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[133, 138]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importedFromOtherNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {toNum} from './util';\nconst transform = toNum;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [], + "skip": "needs declarations from another file; oxc compiles one file at a time (imported consts, types and transforms are opaque)" + }, + { + "name": "probe: shorthandRef-importedShorthandDirect", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {toNum as transform} from './util';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[176, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-nsImportMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport * as u from './util';\nconst transform = u.toNum;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\n" + }, + "diagnostics": [ + "Input transform function could not be referenced Value is a reference to 'toNum'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-nsImportMemberDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport * as u from './util';\nconst transform = u.toNum;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\n" + }, + "diagnostics": [ + "Input transform function could not be referenced Value is a reference to 'toNum'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-coreNsMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport * as core from '@angular/core';\nconst transform = core.booleanAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform function could not be referenced Value is a reference to 'booleanAttribute'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-ternary", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = true ? booleanAttribute : numberAttribute;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = parseInt;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",parseInt]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-spreadObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute;\nconst o = {name: 'x', transform};\n@Directive({selector: '[d]', inputs: [o]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-decoOpts", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nconst transform = booleanAttribute;\nconst OPTS = {transform};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-aliasedImportConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {booleanAttribute as ba} from '@angular/core';\nconst transform = ba;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ba]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-aliasedImportNamed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {booleanAttribute as ba} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: ba}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ba]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-aliasedImportDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {booleanAttribute as ba} from '@angular/core';\nconst t = ba;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: t}) x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ba]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-helperParamMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction make(o: any) { return [{ name: 'x', transform: o.t }]; }\n@Directive({selector: '[d]', inputs: make({ t: booleanAttribute })})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-helperParamMemberAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {booleanAttribute as ba} from '@angular/core';\nfunction make(o: any) { return [{ name: 'x', transform: o.t }]; }\n@Directive({selector: '[d]', inputs: make({ t: ba })})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ba]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-helperParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nfunction make(t: any) { return [{ name: 'x', transform: t }]; }\nconst b = booleanAttribute;\n@Directive({selector: '[d]', inputs: make(b)})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importCoreShorthand", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {booleanAttribute as transform} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[194, 203]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importCoreShorthandDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, numberAttribute} from '@angular/core';\nimport {booleanAttribute as transform} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform}) x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[185, 194]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importNsShorthand", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport * as transform from './util';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\nexport const NAME = 'x';\nexport const ALIAS = 'y';\nexport const REQ = true;\nexport class U { static fn(v: string) { return 1; } }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[170, 179]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importName", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {NAME as name} from './util';\n@Directive({selector: '[d]', inputs: [{name}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\nexport const NAME = 'x';\nexport const ALIAS = 'y';\nexport const REQ = true;\nexport class U { static fn(v: string) { return 1; } }\n" + }, + "diagnostics": [ + "Value at position 0 of @Directive.inputs array must have a \"name\" property Value could not be determined statically." + ], + "diagnosticSpans": [[157, 165]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importAlias", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {ALIAS as alias} from './util';\n@Directive({selector: '[d]', inputs: [{name: 'x', alias}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\nexport const NAME = 'x';\nexport const ALIAS = 'y';\nexport const REQ = true;\nexport class U { static fn(v: string) { return 1; } }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {REQ as required} from './util';\n@Directive({selector: '[d]', inputs: [{name: 'x', required}]})\nexport class Dir {\n x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\nexport const NAME = 'x';\nexport const ALIAS = 'y';\nexport const REQ = true;\nexport class U { static fn(v: string) { return 1; } }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importAliasDeco", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\nimport {ALIAS as alias} from './util';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias}) x!: any;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\nexport const NAME = 'x';\nexport const ALIAS = 'y';\nexport const REQ = true;\nexport class U { static fn(v: string) { return 1; } }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalShorthandMissing", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute, numberAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform}]})\nexport class Dir {\n x!: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[133, 142]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signal-namespaceAliasOptions", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Directive({selector: '[d]'})\nexport class Dir {\n a = ng.input(0, {alias: 'aa'});\n b = ng.model(0, {alias: 'bb'});\n c = ng.output({alias: 'cc'});\n d = ng.input.required({alias: 'dd'});\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[1,\"aa\",\"a\"],b:[1,\"bb\",\"b\"],d:[1,\"dd\",\"d\"]}", + "outputs": "{b:\"bbChange\",c:\"cc\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importQuery", + "origin": "probe", + "files": { + "test.ts": "import {Directive, ViewChild} from '@angular/core';\nimport {foo} from './util';\n@Directive({selector: '[d]', queries: {foo}})\nexport class Dir {\n foo: any;\n}\n", + "util.ts": "import {ViewChild} from '@angular/core';\nexport const foo = new ViewChild('x');\nexport const x = 'x';\n" + }, + "diagnostics": ["Decorator query metadata must be an instance of a query type"], + "diagnosticSpans": [[118, 123]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-importQueriesSpread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, ViewChild} from '@angular/core';\nimport {foo} from './util';\nconst q = {foo};\n@Directive({selector: '[d]', queries: q})\nexport class Dir {\n foo: any;\n}\n", + "util.ts": "import {ViewChild} from '@angular/core';\nexport const foo = new ViewChild('x');\nexport const x = 'x';\n" + }, + "diagnostics": ["Decorator queries metadata must be an object literal"], + "diagnosticSpans": [[135, 136]], + "classes": {}, + "dtsImports": [] + }, { "name": "probe: queries-member-ViewChildren-emitDistinctChangesOnly-constFalse", "origin": "probe", diff --git a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs index 8bb4913ce..0b51889ef 100644 --- a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs +++ b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs @@ -224,3 +224,26 @@ fn explicit_view_child_decorator_blocks_query_synthesis() { "synthesis must skip when explicit query decorator coexists. Got:\n{out}" ); } + +#[test] +fn only_angular_initializer_apis_are_lowered() { + // ngtsc's JIT transform recognises the APIs by their `@angular/core` import + // (`tryParseInitializerApi`): an alias counts; a function from another + // module, a local one or an undeclared one doesn't. + let out = compile_jit( + "import { Component, input as inp } from '@angular/core';\n\ + import * as ng from '@angular/core';\n\ + import { output } from './other';\n\ + function model(v?: any): any { return v; }\n\ + @Component({ selector: 'c', template: '', standalone: true })\n\ + export class C {\n a = inp(0);\n b = output();\n c = model(0);\n d = viewChild('x');\n e = ng.contentChild.required('y');\n}\n", + ); + let props = &out[out.find("propDecorators").expect("propDecorators missing")..]; + let props: String = props.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + props.starts_with( + r#"propDecorators={a:[{type:i0.Input,args:[{isSignal:true,alias:"a",required:false,transform:undefined}]}],e:[{type:i0.ContentChild,args:["y",{isSignal:true}]}]};"# + ), + "Got:\n{out}" + ); +} diff --git a/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs index c2f8afc54..2790e693b 100644 --- a/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs +++ b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs @@ -418,3 +418,27 @@ fn namespaced_required_signal_model_detected() { assert!(md.contains("Output"), "core.model.required output not detected:\n{md}"); assert!(md.contains("required:true"), "required flag missing for core.model.required:\n{md}"); } + +/// Like ngtsc, only Angular's `input()`, `viewChild()`, ... get a synthetic +/// decorator: imported from `@angular/core` by name (under any alias) or +/// through a namespace import. ngtsc 22.1.7 emits exactly +/// `{ a: [{ type: i0.Input, args: [{ isSignal: true, alias: "a", required: false }] }], +/// d: [{ type: i0.ViewChild, args: ['x', { isSignal: true }] }] }` for this +/// class: `output` comes from another module and `model` is a local function. +#[test] +fn only_angular_initializer_apis_get_synthetic_decorators() { + let md = metadata_region(&compile( + "import { Component, input as inp, viewChild } from '@angular/core';\n\ + import { output } from './other';\n\ + function model(v?: any): any { return v; }\n\ + @Component({ selector: 'c', template: 'x' })\n\ + export class C {\n a = inp(0);\n b = output();\n c = model(0);\n d = viewChild('x');\n}\n", + )); + let md: String = md.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + md.contains( + r#"{a:[{type:i0.Input,args:[{isSignal:true,alias:"a",required:false}]}],d:[{type:i0.ViewChild,args:["x",{isSignal:true}]}]}"# + ), + "{md}" + ); +} diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index c4601f87f..02b7d1c70 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -24,7 +24,7 @@ use oxc_angular_compiler::{ ViewEncapsulation as RustViewEncapsulation, build_ctor_params_metadata as core_build_ctor_params_metadata, build_decorator_metadata_array as core_build_decorator_metadata_array, - build_prop_decorators_metadata as core_build_prop_decorators_metadata, + build_prop_decorators_metadata_in as core_build_prop_decorators_metadata, compile_template_for_hmr, compile_template_to_js_with_options, encapsulate_style as rust_encapsulate_style, generate_hmr_update_module_from_js, generate_style_update_module, @@ -2076,12 +2076,16 @@ pub fn compile_class_metadata_sync( Some(&source), ); - // Build property decorators metadata + // Build property decorators metadata. Signal members get a synthetic + // decorator only when they call Angular's `input()`, `viewChild()`, ..., + // which the file's imports tell. + let string_consts = oxc_angular_compiler::collect_string_consts(&allocator, program); let prop_decorators_expr = core_build_prop_decorators_metadata( &allocator, class, Some(&source), &mut namespace_registry, + Some(&string_consts), ); // Create R3ClassMetadata diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 741167030..2a06f4113 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -70,3 +70,38 @@ export class X { expect(component.host?.listeners ?? []).toEqual([]) }) }) + +// Like ngtsc, a signal member counts only when it calls Angular's own `input()`, +// `output()`, `model()`, `viewChild()`, ...: imported from `@angular/core` by +// name (under any alias) or through a namespace import. +describe('extractComponentMetadataSync signal members', () => { + it('reads only the ones that call Angular APIs', () => { + const [component] = extractComponentMetadataSync( + ` +import { Component, input as inp, output } from '@angular/core'; +import * as ng from '@angular/core'; +import { model, viewChild } from './other'; +function contentChild(x: string): any { return null; } + +@Component({ selector: 'app-x', template: '
' }) +export class X { + a = inp(0); + b = ng.model.required(); + c = output(); + d = model(0); + e = viewChild('ref'); + f = contentChild('ref'); + g = ng.viewChild('ref'); +} +`, + 'x.component.ts', + ) + expect(component.inputs?.map((i) => [i.classPropertyName, i.isSignal])).toEqual([ + ['a', true], + ['b', true], + ]) + expect(component.outputs?.map((o) => o.classPropertyName)).toEqual(['b', 'c']) + expect(component.viewQueries?.map((q) => q.propertyName)).toEqual(['g']) + expect(component.queries ?? []).toEqual([]) + }) +}) From 36b7576c878abe62afcd3931175b8ca0f6a54818 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 23:54:26 +0800 Subject: [PATCH 02/12] fix(directive): recognise member decorators by their @angular/core import, and keep JIT's i0 free Member decorators (`@Input`, `@Output`, `@HostBinding`, `@HostListener`, `@ViewChild(ren)`, `@ContentChild(ren)`) were matched by name. Like ngtsc, the compiler now only treats one imported from `@angular/core` as Angular's: by name under any alias (`import { Input as In }`), or through a namespace import (`@core.Input()`). A same-named decorator from another module, a local function or an undeclared name is left alone: - not compiled into the definition, and its options aren't checked (`@Input({ transform: 5 })` from `./foreign` no longer reports "Input transform must be a function") - not removed from the class, and not listed in `setClassMetadata` - in JIT, applied with `__decorate` instead of moved to `propDecorators` An aliased or namespaced one is compiled, removed from the class (a namespaced `@core.Input()` was compiled but left on the class) and, in JIT, listed in `propDecorators` as written (`type: In`, `type: core.Input`). The public `extract_*` functions, which don't get the file's imports, still match by name. JIT synthesis of signal-member decorators referenced `@angular/core` as a new `import * as i0`, which redeclared any `i0` the file already had (a SyntaxError; e.g. `import { input as i0 }`). Like ngtsc's `ImportManager`, it now reuses the file's own `import * as x from '@angular/core'`, or names the new import `i0_1`, `i0_2`, ... when `i0` is taken. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/class_metadata/builders.rs | 18 +-- .../src/component/decorator.rs | 79 ++++++++-- .../src/component/transform.rs | 135 ++++++++++++++---- .../src/directive/decorator.rs | 6 + .../oxc_angular_compiler/src/directive/mod.rs | 10 +- .../src/directive/property_decorators.rs | 73 +++++++--- .../tests/decorator_metadata_ngtsc_test.rs | 30 +++- .../fixtures/decorator_metadata_ngtsc.json | 99 +++++++++++++ .../tests/jit_signal_initializer_test.rs | 52 ++++++- 9 files changed, 423 insertions(+), 79 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index aabbd167b..21c5226ad 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -423,10 +423,11 @@ pub fn build_prop_decorators_metadata<'a>( /// [`build_prop_decorators_metadata`] for a class in the file `consts` was /// collected from. /// -/// A signal member (`input()`, `viewChild()`, ...) gets a synthetic decorator -/// only when it calls Angular's function, imported from `@angular/core` (or -/// `@angular/core/rxjs-interop`) by name, under any alias, or through a -/// namespace import. +/// A member decorator (`@Input()`, ...) is listed only when it's Angular's, +/// imported from `@angular/core` by name, under any alias, or through a +/// namespace import. A signal member (`input()`, `viewChild()`, ...) gets a +/// synthetic decorator only when it calls Angular's function, imported the +/// same way (`outputFromObservable` from `@angular/core/rxjs-interop`). pub fn build_prop_decorators_metadata_in<'a>( allocator: &'a Allocator, class: &Class<'a>, @@ -465,12 +466,13 @@ pub fn build_prop_decorators_metadata_in<'a>( continue; }; - // Filter to Angular property decorators + // Filter to Angular property decorators: with the file's imports, only + // `@angular/core`'s (see `angular_member_decorator`), like ngtsc. let angular_decorators: std::vec::Vec<_> = decorators .iter() - .filter(|d| { - let name = get_decorator_name(d); - name.is_some_and(|n| ANGULAR_PROP_DECORATORS.contains(&n)) + .filter(|d| match consts { + Some(_) => crate::directive::angular_member_decorator(d, consts).is_some(), + None => get_decorator_name(d).is_some_and(|n| ANGULAR_PROP_DECORATORS.contains(&n)), }) .collect(); diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 0a093e6b6..59c592e2c 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -1111,10 +1111,18 @@ pub fn collect_constructor_decorator_spans(class: &Class<'_>, spans: &mut std::v /// Member decorators like `@Input()`, `@Output()`, `@HostBinding()`, `@HostListener()`, /// `@ViewChild()`, `@ViewChildren()`, `@ContentChild()`, and `@ContentChildren()` need /// to be removed from the output since their metadata is compiled into the definition. +/// Like ngtsc, only Angular's own count (imported from `@angular/core`, under any +/// alias or through a namespace import, see +/// [`crate::directive::angular_member_decorator`]); a same-named decorator from +/// another module is left in place. /// /// These spans are used by `transform.rs` to remove the decorators from the /// source text during transformation. -pub fn collect_member_decorator_spans(class: &Class<'_>, spans: &mut std::vec::Vec) { +pub fn collect_member_decorator_spans( + class: &Class<'_>, + consts: &StringConsts<'_>, + spans: &mut std::vec::Vec, +) { for element in &class.body.body { let decorators = match element { ClassElement::PropertyDefinition(prop) => &prop.decorators, @@ -1130,15 +1138,8 @@ pub fn collect_member_decorator_spans(class: &Class<'_>, spans: &mut std::vec::V }; for decorator in decorators { - if let Some(name) = get_decorator_name(&decorator.expression) { - // Only collect Angular-specific member decorators - match name.as_str() { - "Input" | "Output" | "HostBinding" | "HostListener" | "ViewChild" - | "ViewChildren" | "ContentChild" | "ContentChildren" => { - spans.push(decorator.span); - } - _ => {} - } + if crate::directive::angular_member_decorator(decorator, Some(consts)).is_some() { + spans.push(decorator.span); } } } @@ -2351,6 +2352,7 @@ mod tests { #[test] fn test_extract_host_binding_decorator() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2371,6 +2373,7 @@ mod tests { #[test] fn test_extract_host_binding_without_name() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2391,6 +2394,7 @@ mod tests { #[test] fn test_extract_host_listener_decorator() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2412,6 +2416,7 @@ mod tests { #[test] fn test_extract_host_listener_with_args() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2433,6 +2438,7 @@ mod tests { #[test] fn test_extract_host_listener_with_multiple_args() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2454,6 +2460,7 @@ mod tests { #[test] fn test_extract_multiple_host_decorators() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2476,6 +2483,7 @@ mod tests { fn test_merge_host_decorators_with_host_property() { // Test that @HostBinding/@HostListener are merged with @Component({ host: {} }) let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2501,6 +2509,7 @@ mod tests { #[test] fn test_host_binding_on_getter() { let code = r#" + import {Component, HostBinding, HostListener} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3228,6 +3237,14 @@ mod tests { panic!("No class found in code"); } + /// [`collect_member_decorator_spans`] for `class`, declared in `code`. + fn collect_member_decorators(code: &str, class: &Class<'_>, spans: &mut std::vec::Vec) { + let allocator = Allocator::default(); + let parser_ret = Parser::new(&allocator, code, SourceType::tsx()).parse(); + let consts = crate::directive::collect_string_consts(&allocator, &parser_ret.program); + collect_member_decorator_spans(class, &consts, spans); + } + #[test] fn test_collect_constructor_decorator_spans() { let code = r#" @@ -3294,6 +3311,7 @@ mod tests { #[test] fn test_collect_member_decorator_spans() { let code = r#" + import {Component, Input, Output, HostBinding, HostListener, ViewChild, ViewChildren, ContentChild, ContentChildren} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3314,7 +3332,7 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_member_decorator_spans(class, &mut spans); + collect_member_decorators(code, class, &mut spans); // Should collect 9 member decorators (all @Input, @Output, @Host*, @*Child) assert_eq!(spans.len(), 9); @@ -3324,6 +3342,7 @@ mod tests { #[test] fn test_collect_member_decorator_spans_ignores_non_angular() { let code = r#" + import {Component, Input} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3336,13 +3355,47 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_member_decorator_spans(class, &mut spans); + collect_member_decorators(code, class, &mut spans); // Should only collect @Input, ignoring custom decorators assert_eq!(spans.len(), 1); }); } + /// Like ngtsc, only `@angular/core`'s decorators are compiled and removed: + /// under an alias or a namespace too, but not another module's, a local + /// or an undeclared one with the same name. + #[test] + fn test_collect_member_decorator_spans_by_import() { + let code = r#" + import {Component, Input as In, Output} from '@angular/core'; + import * as core from '@angular/core'; + import {HostBinding} from './other'; + import * as other from './other'; + function ViewChild(s: string): any { return () => {}; } + @Component({ + selector: 'app-test', + template: '' + }) + class TestComponent { + @In() aliased: string; + @core.Input() namespaced: string; + @Output() out: any; + @HostBinding('class.a') foreign = true; + @other.Input() foreignNamespace: string; + @ViewChild('ref') local: any; + @ContentChild('ref') undeclared: any; + } + "#; + with_first_class(code, |class| { + let mut spans = std::vec::Vec::new(); + collect_member_decorators(code, class, &mut spans); + let texts: std::vec::Vec<&str> = + spans.iter().map(|s| &code[s.start as usize..s.end as usize]).collect(); + assert_eq!(texts, ["@In()", "@core.Input()", "@Output()"]); + }); + } + #[test] fn test_collect_member_decorator_spans_empty() { let code = r#" @@ -3357,7 +3410,7 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_member_decorator_spans(class, &mut spans); + collect_member_decorators(code, class, &mut spans); // No Angular member decorators assert_eq!(spans.len(), 0); diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index a18ebb0d9..ba49fcc38 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1220,6 +1220,7 @@ fn extract_all_jit_member_decorators( source: &str, class: &oxc_ast::ast::Class<'_>, string_consts: &crate::directive::StringConsts<'_>, + core_namespace: &str, ) -> (std::vec::Vec, std::vec::Vec) { use oxc_ast::ast::{ClassElement, MethodDefinitionKind, PropertyKey}; @@ -1264,13 +1265,8 @@ fn extract_all_jit_member_decorators( rustc_hash::FxHashSet::default(); for decorator in decorators { - let (dec_name, call_args) = match &decorator.expression { + let (callee, call_args) = match &decorator.expression { Expression::CallExpression(call) => { - let name = match &call.callee { - Expression::Identifier(id) => id.name.to_string(), - Expression::StaticMemberExpression(m) => m.property.name.to_string(), - _ => continue, - }; let args = if call.arguments.is_empty() { None } else { @@ -1278,18 +1274,30 @@ fn extract_all_jit_member_decorators( let end = call.arguments.last().unwrap().span().end; Some(source[start as usize..end as usize].to_string()) }; - (name, args) + (&call.callee, args) } - Expression::Identifier(id) => (id.name.to_string(), None), + expr => (expr, None), + }; + let dec_name = match callee { + Expression::Identifier(id) => id.name.as_str(), + Expression::StaticMemberExpression(m) => m.property.name.as_str(), _ => continue, }; - if ANGULAR_FIELD_DECORATORS.contains(&dec_name.as_str()) { - // Angular field decorator → goes into propDecorators - explicit_field_decorators.insert(dec_name.clone()); - angular_decs.push(JitParamDecorator { name: dec_name, args: call_args }); - } else if !ANGULAR_DECORATOR_NAMES.contains(&dec_name.as_str()) { - // Non-Angular decorator → goes into __decorate() call + if let Some(field) = + crate::directive::angular_member_decorator(decorator, Some(string_consts)) + { + // Angular field decorator (imported from `@angular/core`) → goes into + // propDecorators, referenced as written (`In`, `core.Input`), like ngtsc. + explicit_field_decorators.insert(field.to_string()); + let name = + source[callee.span().start as usize..callee.span().end as usize].to_string(); + angular_decs.push(JitParamDecorator { name, args: call_args }); + } else if ANGULAR_FIELD_DECORATORS.contains(&dec_name) + || !ANGULAR_DECORATOR_NAMES.contains(&dec_name) + { + // Non-Angular decorator (another module's `@Input` too) → goes into + // __decorate() call let expr_start = decorator.expression.span().start; let expr_end = decorator.expression.span().end; non_angular_texts.push(source[expr_start as usize..expr_end as usize].to_string()); @@ -1310,6 +1318,7 @@ fn extract_all_jit_member_decorators( &member_name, &explicit_field_decorators, string_consts, + core_namespace, ); angular_decs.extend(synthesized); } @@ -1410,6 +1419,57 @@ fn classify_initializer_api( /// an undefined identifier and throw `ReferenceError` at module-evaluation time. pub(crate) const JIT_ANGULAR_CORE_NS: &str = "i0"; +/// Every identifier a file has, like TypeScript's `SourceFile.identifiers`. +#[derive(Default)] +struct FileIdentifiers(rustc_hash::FxHashSet); + +impl<'a> oxc_ast_visit::Visit<'a> for FileIdentifiers { + fn visit_identifier_reference(&mut self, it: &oxc_ast::ast::IdentifierReference<'a>) { + self.0.insert(it.name.to_string()); + } + fn visit_binding_identifier(&mut self, it: &oxc_ast::ast::BindingIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } + fn visit_identifier_name(&mut self, it: &oxc_ast::ast::IdentifierName<'a>) { + self.0.insert(it.name.to_string()); + } + fn visit_label_identifier(&mut self, it: &oxc_ast::ast::LabelIdentifier<'a>) { + self.0.insert(it.name.to_string()); + } +} + +/// The namespace JIT synthesis references `@angular/core` through, and whether +/// the file already imports it, like ngc's `ImportManager`: the last +/// `import * as x from '@angular/core'` that isn't type-only is reused; +/// otherwise a new [`JIT_ANGULAR_CORE_NS`] import, renamed `i0_1`, `i0_2`, ... +/// while the file uses that identifier anywhere (an `input as i0` import, a +/// `const i0`, ...), so the added import can't redeclare it. +fn jit_angular_core_namespace(program: &oxc_ast::ast::Program<'_>) -> (String, bool) { + for stmt in program.body.iter().rev() { + let Statement::ImportDeclaration(import) = stmt else { continue }; + if import.source.value != "@angular/core" || import.import_kind.is_type() { + continue; + } + let namespace = import.specifiers.iter().flatten().find_map(|spec| match spec { + ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns) => Some(ns.local.name), + _ => None, + }); + if let Some(namespace) = namespace { + return (namespace.to_string(), true); + } + } + + let mut identifiers = FileIdentifiers::default(); + oxc_ast_visit::Visit::visit_program(&mut identifiers, program); + let mut name = JIT_ANGULAR_CORE_NS.to_string(); + let mut counter = 1; + while identifiers.0.contains(&name) { + name = format!("{JIT_ANGULAR_CORE_NS}_{counter}"); + counter += 1; + } + (name, false) +} + /// Inspect a property initializer; if it matches a recognized signal initializer API, /// return the synthesized `propDecorators` entries that JIT runtime needs. /// @@ -1417,15 +1477,16 @@ pub(crate) const JIT_ANGULAR_CORE_NS: &str = "i0"; /// property (e.g. `Input`, `Output`); we skip synthesis when the user-authored decorator /// already covers the binding (matches upstream behavior — explicit decorator wins). /// -/// Synthesized decorator names are namespace-prefixed (e.g. `i0.Input`) — see -/// [`JIT_ANGULAR_CORE_NS`]. The caller is responsible for emitting the matching -/// `import * as i0 from "@angular/core"` when any synthesis occurred. +/// Synthesized decorator names are prefixed with `core_namespace` (e.g. `i0.Input`, +/// see [`jit_angular_core_namespace`]). The caller is responsible for emitting the +/// matching `import * as i0 from "@angular/core"` when any synthesis occurred. fn synthesize_signal_api_decorators( source: &str, initializer: &Expression<'_>, field_name: &str, existing: &rustc_hash::FxHashSet, string_consts: &crate::directive::StringConsts<'_>, + core_namespace: &str, ) -> std::vec::Vec { let unwrapped = unwrap_jit_initializer(initializer); let Expression::CallExpression(call) = unwrapped else { return std::vec::Vec::new() }; @@ -1449,7 +1510,7 @@ fn synthesize_signal_api_decorators( required = required, ); std::vec::Vec::from([JitParamDecorator { - name: format!("{JIT_ANGULAR_CORE_NS}.Input"), + name: format!("{core_namespace}.Input"), args: Some(args), }]) } @@ -1464,7 +1525,7 @@ fn synthesize_signal_api_decorators( .unwrap_or_else(|| field_name.to_string()); let args = format!("\"{}\"", escape_js_string(&alias)); std::vec::Vec::from([JitParamDecorator { - name: format!("{JIT_ANGULAR_CORE_NS}.Output"), + name: format!("{core_namespace}.Output"), args: Some(args), }]) } @@ -1486,11 +1547,11 @@ fn synthesize_signal_api_decorators( let output_args = format!("\"{}Change\"", escape_js_string(&alias)); std::vec::Vec::from([ JitParamDecorator { - name: format!("{JIT_ANGULAR_CORE_NS}.Input"), + name: format!("{core_namespace}.Input"), args: Some(input_args), }, JitParamDecorator { - name: format!("{JIT_ANGULAR_CORE_NS}.Output"), + name: format!("{core_namespace}.Output"), args: Some(output_args), }, ]) @@ -1533,7 +1594,7 @@ fn synthesize_signal_api_decorators( }; let args = format!("{locator_text}, {options_text}"); std::vec::Vec::from([JitParamDecorator { - name: format!("{JIT_ANGULAR_CORE_NS}.{decorator_name}"), + name: format!("{core_namespace}.{decorator_name}"), args: Some(args), }]) } @@ -1543,8 +1604,11 @@ fn synthesize_signal_api_decorators( /// Returns `true` when any field of any JIT class has a synthesized decorator /// (signal API lowering) that references the `@angular/core` namespace. Used to /// gate the emission of `import * as i0 from "@angular/core"`. -fn jit_classes_need_angular_core_namespace(jit_classes: &[JitClassInfo]) -> bool { - let prefix = format!("{JIT_ANGULAR_CORE_NS}."); +fn jit_classes_need_angular_core_namespace( + jit_classes: &[JitClassInfo], + core_namespace: &str, +) -> bool { + let prefix = format!("{core_namespace}."); jit_classes.iter().any(|info| { info.member_decorators .iter() @@ -2055,6 +2119,9 @@ fn transform_angular_file_jit( let import_map = build_import_map(allocator, &parser_ret.program.body, options.resolved_imports.as_ref()); + // How synthesized `propDecorators` reference `@angular/core` (`i0.Input`). + let (core_namespace, core_namespace_imported) = jit_angular_core_namespace(&parser_ret.program); + // 3. Walk AST to find Angular-decorated classes let mut jit_classes: std::vec::Vec = std::vec::Vec::new(); let mut resource_counter: u32 = 0; @@ -2138,7 +2205,7 @@ fn transform_angular_file_jit( // Extract Angular and non-Angular member decorators let (member_decorators, non_angular_member_decorators) = - extract_all_jit_member_decorators(source, class, &string_consts); + extract_all_jit_member_decorators(source, class, &string_consts, &core_namespace); jit_classes.push(JitClassInfo { class_name, @@ -2179,9 +2246,11 @@ fn transform_angular_file_jit( // `i0.Input`/`i0.Output`/etc. at runtime). let mut additional_imports = String::new(); additional_imports.push_str("import { __decorate } from \"tslib\";\n"); - if jit_classes_need_angular_core_namespace(&jit_classes) { + if !core_namespace_imported + && jit_classes_need_angular_core_namespace(&jit_classes, &core_namespace) + { additional_imports - .push_str(&format!("import * as {JIT_ANGULAR_CORE_NS} from \"@angular/core\";\n")); + .push_str(&format!("import * as {core_namespace} from \"@angular/core\";\n")); } for (import_name, specifier) in &resource_imports { additional_imports.push_str(&format!("import {} from \"{}\";\n", import_name, specifier)); @@ -2676,7 +2745,11 @@ pub fn transform_angular_file( &mut decorator_spans_to_remove, ); // Collect member decorators (@Input, @Output, @HostBinding, etc.) - collect_member_decorator_spans(class, &mut decorator_spans_to_remove); + collect_member_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Store the ɵfac/ɵcmp definitions. // Order: ɵfac BEFORE ɵcmp (Angular convention). @@ -2924,7 +2997,11 @@ pub fn transform_angular_file( // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); // Collect member decorators (@Input, @Output, @HostBinding, etc.) - collect_member_decorator_spans(class, &mut decorator_spans_to_remove); + collect_member_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Resolve namespace imports for directive constructor deps. // Directives can inject services from other modules (e.g., Store from @ngrx/store), diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index d44563967..ccb75dac9 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -2050,6 +2050,7 @@ mod tests { #[test] fn test_extract_directive_with_inputs_from_class() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective { @Input() name: string; @@ -2068,6 +2069,7 @@ mod tests { #[test] fn test_extract_directive_with_outputs_from_class() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective { @Output() clicked = new EventEmitter(); @@ -2086,6 +2088,7 @@ mod tests { #[test] fn test_extract_directive_with_host_binding_decorator() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective { @HostBinding('class.active') isActive = false; @@ -2101,6 +2104,7 @@ mod tests { #[test] fn test_extract_directive_with_host_listener_decorator() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective { @HostListener('click') onClick() {} @@ -2116,6 +2120,7 @@ mod tests { #[test] fn test_extract_directive_merges_host_from_decorator_and_class() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -2227,6 +2232,7 @@ mod tests { #[test] fn test_full_directive_decorator() { let code = r#" + import {Directive, Input, Output, HostBinding, HostListener} from '@angular/core'; @Directive({ selector: '[appComplete]', standalone: true, diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 957f050f6..eb955babe 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -48,14 +48,14 @@ pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; +pub(crate) use property_decorators::{ + angular_member_decorator, extract_host_bindings_in, extract_host_listeners_in, + parse_decorator_queries, try_parse_signal_input, try_parse_signal_model, + try_parse_signal_output, unwrap_initializer_api_expr, +}; pub use property_decorators::{ extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_input_metadata_in, extract_output_metadata, extract_output_metadata_in, extract_view_queries, }; -pub(crate) use property_decorators::{ - extract_host_bindings_in, extract_host_listeners_in, parse_decorator_queries, - try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, - unwrap_initializer_api_expr, -}; pub use query::{create_content_queries_function, create_view_queries_function}; diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 3615d2b86..b8c25d9b2 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -39,32 +39,63 @@ fn is_core_namespace(consts: Option<&super::StringConsts<'_>>, name: &str) -> bo .is_some_and(|import| import.module == "@angular/core" && import.imported.is_none()) } -/// Find a decorator by name from a list of decorators. -/// -/// Searches for decorators that are either: -/// - Simple identifiers: `@Input` -/// - Call expressions: `@Input()` or `@Input('alias')` -/// - Either of those through a namespace import of `@angular/core` in the -/// file `consts` was collected from (see [`is_core_namespace`]): `@core.Input()` -/// -/// Returns the first matching decorator. +/// Angular's member decorators, which are compiled into the definition. +const MEMBER_DECORATORS: &[&str] = &[ + "Input", + "Output", + "HostBinding", + "HostListener", + "ViewChild", + "ViewChildren", + "ContentChild", + "ContentChildren", +]; + +/// Which of Angular's member decorators ([`MEMBER_DECORATORS`]) `decorator` +/// is: `@Input`, `@Input()`, ... With the file `consts` was collected from, +/// only one imported from `@angular/core`, like ngtsc: by name under any alias +/// (`@In()` for `import { Input as In }`) or through a namespace import +/// (`@core.Input()`, see [`is_core_namespace`]); another module's or a local +/// `Input` isn't Angular's. Without the file (the public `extract_*` +/// functions), any decorator with that name. +pub(crate) fn angular_member_decorator( + decorator: &Decorator<'_>, + consts: Option<&super::StringConsts<'_>>, +) -> Option<&'static str> { + let callee = match &decorator.expression { + Expression::CallExpression(call) => &call.callee, + expr => expr, + }; + let name = match callee { + Expression::Identifier(id) => match consts { + Some(consts) => { + let import = consts.scope().import(&id.name)?; + if import.module != "@angular/core" { + return None; + } + import.imported? + } + None => id.name.as_str(), + }, + Expression::StaticMemberExpression(m) => match &m.object { + Expression::Identifier(ns) if is_core_namespace(consts, &ns.name) => { + m.property.name.as_str() + } + _ => return None, + }, + _ => return None, + }; + MEMBER_DECORATORS.iter().copied().find(|n| *n == name) +} + +/// Find one of Angular's member decorators (`name`) in a list of decorators +/// (see [`angular_member_decorator`]). Returns the first match. fn find_decorator_by_name<'a>( decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, name: &str, consts: Option<&super::StringConsts<'_>>, ) -> Option<&'a Decorator<'a>> { - let is_name = |expr: &Expression<'_>| match expr { - Expression::Identifier(id) => id.name == name, - Expression::StaticMemberExpression(m) => { - m.property.name == name - && matches!(&m.object, Expression::Identifier(ns) if is_core_namespace(consts, &ns.name)) - } - _ => false, - }; - decorators.iter().find(|d| match &d.expression { - Expression::CallExpression(call) => is_name(&call.callee), - expr => is_name(expr), - }) + decorators.iter().find(|d| angular_member_decorator(d, consts) == Some(name)) } /// The options argument of an `@Input(...)` decorator, if any. diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 2bef921b7..61c9d48b1 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 872, "fixtures compared"); + assert_eq!(compared, 878, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -1169,3 +1169,31 @@ export class Cmp {{ ); } } + +/// Like ngtsc, a member decorator is Angular's only when it's imported from +/// `@angular/core`, under any alias or through a namespace import. Only those +/// are compiled (the snapshot's `memberDecorator-*` probes), removed from the +/// class and listed in `setClassMetadata`. Another module's `@Input` is left on +/// the class and its options aren't checked, as ngtsc 22.1.7 does. +#[test] +fn only_angular_member_decorators_are_compiled_and_removed() { + let source = "import {Directive, Input as In} from '@angular/core'; +import * as core from '@angular/core'; +import {Input, HostListener} from './other'; +@Directive({selector: '[d]'}) +export class Dir { + @In() a: any; + @core.Output() b: any; + @Input({transform: 5}) c: any; + @HostListener('click') d() {} +} +"; + let result = transform(source); + assert!(errors(&result, source).is_empty(), "{:?}", errors(&result, source)); + let code = strip(&result.code); + assert!(!code.contains("@In()") && !code.contains("@core.Output()"), "{}", result.code); + assert!(code.contains("@Input({transform:5})c:any;"), "{}", result.code); + assert!(code.contains("@HostListener('click')d(){}"), "{}", result.code); + assert!(code.contains(r#"inputs:{a:"a"},outputs:{b:"b"}}"#), "{}", result.code); + assert!(code.contains("{a:[{type:In}],b:[{type:core.Output}]}"), "{}", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index f8ff5e799..522ba8f39 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -9475,6 +9475,105 @@ "classes": {}, "dtsImports": [] }, + { + "name": "probe: memberDecorator-otherModuleTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {Input} from './foreign';\n@Directive({selector: '[d]'})\nexport class D { @Input({transform: 5}) x: any; }\n", + "foreign.ts": "export function Input(o?: any): any { return () => {}; }\nexport function Output(o?: any): any { return () => {}; }\nexport function HostBinding(o?: any): any { return () => {}; }\nexport function HostListener(e?: any, a?: any): any { return () => {}; }\nexport function ViewChild(p?: any, o?: any): any { return () => {}; }\nexport function ContentChildren(p?: any, o?: any): any { return () => {}; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: memberDecorator-aliasedFromCore", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input as In, Output as Out, HostBinding as HB, HostListener as HL, ViewChild as VC, ContentChildren as CC, EventEmitter} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class D {\n @In() x: any;\n @In({alias: 'yy', transform: (v: string) => v}) y: any;\n @Out() changed = new EventEmitter();\n @HB('class.on') on = true;\n @HL('click', ['$event']) onClick(e: any) {}\n @VC('ref') ref: any;\n @CC('item', {descendants: true}) items: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:\"x\",y:[2,\"yy\",\"y\",(v)=>v]}", + "outputs": "{changed:\"changed\"}", + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,[\"item\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.items=_t);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"ref\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.ref=_t.first);}}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_y": "staticngAcceptInputType_y:string;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: memberDecorator-otherModule", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {Input, Output, HostBinding, HostListener, ViewChild, ContentChildren} from './foreign';\n@Directive({selector: '[d]'})\nexport class D {\n @Input() x: any;\n @Output() changed: any;\n @HostBinding('class.on') on = true;\n @HostListener('click', ['$event']) onClick(e: any) {}\n @ViewChild('ref') ref: any;\n @ContentChildren('item') items: any;\n}\n", + "foreign.ts": "export function Input(o?: any): any { return () => {}; }\nexport function Output(o?: any): any { return () => {}; }\nexport function HostBinding(o?: any): any { return () => {}; }\nexport function HostListener(e?: any, a?: any): any { return () => {}; }\nexport function ViewChild(p?: any, o?: any): any { return () => {}; }\nexport function ContentChildren(p?: any, o?: any): any { return () => {}; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: memberDecorator-undeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class D {\n @Input() x: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "D": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: memberDecorator-localFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nfunction Input(o?: any): any { return () => {}; }\n@Directive({selector: '[d]'})\nexport class D {\n @Input({transform: 5}) x: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "D": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: memberDecorator-coreNamespace", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Directive({selector: '[d]'})\nexport class D { @ng.Input() x: any; @ng.Output() y: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:\"x\"}", + "outputs": "{y:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, { "name": "probe: queries-member-ViewChildren-emitDistinctChangesOnly-constFalse", "origin": "probe", diff --git a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs index 0b51889ef..03dc2c717 100644 --- a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs +++ b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs @@ -229,7 +229,8 @@ fn explicit_view_child_decorator_blocks_query_synthesis() { fn only_angular_initializer_apis_are_lowered() { // ngtsc's JIT transform recognises the APIs by their `@angular/core` import // (`tryParseInitializerApi`): an alias counts; a function from another - // module, a local one or an undeclared one doesn't. + // module, a local one or an undeclared one doesn't. The synthesized + // decorators reuse the file's `@angular/core` namespace import, like ngtsc. let out = compile_jit( "import { Component, input as inp } from '@angular/core';\n\ import * as ng from '@angular/core';\n\ @@ -242,8 +243,55 @@ fn only_angular_initializer_apis_are_lowered() { let props: String = props.chars().filter(|c| !c.is_whitespace()).collect(); assert!( props.starts_with( - r#"propDecorators={a:[{type:i0.Input,args:[{isSignal:true,alias:"a",required:false,transform:undefined}]}],e:[{type:i0.ContentChild,args:["y",{isSignal:true}]}]};"# + r#"propDecorators={a:[{type:ng.Input,args:[{isSignal:true,alias:"a",required:false,transform:undefined}]}],e:[{type:ng.ContentChild,args:["y",{isSignal:true}]}]};"# ), "Got:\n{out}" ); } + +#[test] +fn synthesized_decorators_use_a_free_angular_core_namespace() { + // ngtsc's `ImportManager` reuses the file's own `import * as x from + // '@angular/core'`, and otherwise names the new import `i0_1`, `i0_2`, ... + // when the file already has an `i0` identifier: an aliased signal API + // (`input as i0`), a variable, ... A second `i0` binding would be a + // SyntaxError. + let alias = compile_jit(&component("value = i0(0);", "input as i0")); + assert!(alias.contains("import * as i0_1 from \"@angular/core\";"), "{alias}"); + assert!(alias.contains("type: i0_1.Input"), "{alias}"); + assert!(!alias.contains("import * as i0 from"), "{alias}"); + + let variable = compile_jit(&format!( + "const i0 = 1;\nconst i0_1 = 2;\n{}", + component("value = input(i0 + i0_1);", "input") + )); + assert!(variable.contains("import * as i0_2 from \"@angular/core\";"), "{variable}"); + assert!(variable.contains("type: i0_2.Input"), "{variable}"); + + let reused = compile_jit(&format!( + "import * as i0 from '@angular/core';\n{}", + component("value = input(0);", "input") + )); + assert_eq!(reused.matches("from \"@angular/core\"").count(), 2, "{reused}"); + assert!(reused.contains("type: i0.Input"), "{reused}"); +} + +#[test] +fn member_decorators_are_lowered_only_when_imported_from_angular_core() { + // Like ngtsc's JIT transform: `@angular/core`'s decorators (under an alias + // or a namespace too) go to `propDecorators`, as written; another module's + // `@Input` stays a decorator applied with `__decorate`. + let out = compile_jit( + "import { Component, Input as In } from '@angular/core';\n\ + import * as core from '@angular/core';\n\ + import { Input } from './other';\n\ + @Component({ selector: 'c', template: '', standalone: true })\n\ + export class C {\n @In() a: any;\n @core.Output() b: any;\n @Input() c: any;\n}\n", + ); + let compact: String = out.chars().filter(|c| !c.is_whitespace()).collect(); + assert!( + compact.contains("propDecorators={a:[{type:In}],b:[{type:core.Output}]};"), + "Got:\n{out}" + ); + assert!(compact.contains(r#"__decorate([Input()],C.prototype,"c",void0);"#), "Got:\n{out}"); +} From 3969659a988cb7fd804c767f6b7dd40fa089c6d3 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 01:52:08 +0800 Subject: [PATCH 03/12] fix(directive): read member @Input/@Output arguments through the evaluator, like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A same-file value passed to a member decorator lost its alias with no error: `const NAME = 'y'` with `@Input(NAME)` compiled to `x: "x"` (ngtsc: `x: [0, "y", "x"]`), `@Input(OPTS)` with `const OPTS = {alias: 'y', required: true}` dropped the alias and `required`, and `@Output(NAME)` compiled to `e: "e"` (ngtsc: `e: "y"`). Only literals were read. Like ngtsc's `tryParseInputFieldMapping` / `tryParseDecoratorOutput`, the argument is now evaluated (checked against @angular/compiler-cli 22.1.7): - `@Input`: a string (a const, `let`, template literal, `'a' + 'b'`, `as const`, a ternary, a same-file function call, ...) is the alias; an object (a const, a spread, `{alias: NAME}`) gives `alias`, `required` and `transform`. This holds for the `ɵdir`/`ɵcmp` inputs, the `.d.ts` and NAPI's `extractComponentMetadataSync`, which now also gets the transform ngtsc emits. - `@Output`: a string is the alias. - ngtsc's errors: "@Input can have at most one argument, got N argument(s)" (named as written: `@In` for `import {Input as In}`), "@Input decorator argument must resolve to a string or an object literal" (numbers, booleans, `undefined`, arrays, enum members, `declare`d values; `null` is allowed), and the same two for `@Output` ("must resolve to a string", which also rejects `null` and objects). - An imported `@Output(NAME)` (`ns.NAME`, `` `${NAME}` ``, a local copy) gets the "imported from another module" error, like `@Input(OPTS)`. The public `extract_*` functions, which don't get the file's imports, still read literals only. Left as before: ngtsc's "Input/Output 'y' is bound to both ..." error, and `@Output()` on a setter (ngtsc compiles it; oxc doesn't read outputs from methods). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/decorator.rs | 2 - .../src/directive/decorator.rs | 169 ++- .../oxc_angular_compiler/src/directive/mod.rs | 4 +- .../src/directive/property_decorators.rs | 130 +- .../tests/decorator_metadata_ngtsc_test.rs | 39 +- .../fixtures/decorator_metadata_ngtsc.json | 1304 +++++++++++++++++ .../test/extract-component-metadata.test.ts | 39 + 7 files changed, 1597 insertions(+), 90 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 59c592e2c..168f571b8 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -20,7 +20,6 @@ use super::transform::ImportMap; use crate::directive::{ StringConsts, extract_host_bindings_in, extract_host_listeners_in, extract_input_metadata_in, extract_output_metadata_in, merge_by_class_property, parse_decorator_io, - resolve_member_transforms, }; use crate::output::oxc_converter::convert_oxc_expression; @@ -270,7 +269,6 @@ pub fn extract_component_metadata<'a>( extract_input_metadata_in(allocator, class, source_text, Some(consts)), |i| i.class_property_name.as_str(), ); - resolve_member_transforms(allocator, class, source_text, consts, &mut metadata.inputs); metadata.outputs = merge_by_class_property( io.outputs, extract_output_metadata_in(allocator, class, Some(consts)), diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index ccb75dac9..dc32df88f 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -228,7 +228,6 @@ pub fn extract_directive_metadata<'a>( let fields = std::mem::replace(&mut metadata.outputs, Vec::new_in(&allocator)); metadata.outputs = merge_by_class_property(io.outputs, fields, |o| o.0.as_str()); } - resolve_member_transforms(allocator, class, source_text, consts, &mut metadata.inputs); // Merge host metadata from decorator into the existing host metadata if let Some(decorator_host) = host_from_decorator { @@ -971,37 +970,6 @@ pub(crate) fn transform_expression<'a>( } } -/// Give `@Input({ transform })` members the transform expression ngtsc emits -/// (see [`transform_expression`]), in place of the one written. -pub(crate) fn resolve_member_transforms<'a>( - allocator: &'a Allocator, - class: &'a Class<'a>, - source_text: Option<&'a str>, - consts: &StringConsts<'a>, - inputs: &mut [R3InputMetadata<'a>], -) { - let evaluator = Evaluator::new(consts); - for element in &class.body.body { - let (key, decorators) = match element { - ClassElement::PropertyDefinition(p) => (&p.key, &p.decorators), - ClassElement::AccessorProperty(p) => (&p.key, &p.decorators), - ClassElement::MethodDefinition(m) => (&m.key, &m.decorators), - _ => continue, - }; - let Some(name) = key.static_name() else { continue }; - let options = super::property_decorators::input_decorator_options(decorators, consts); - let Some(options) = options else { continue }; - let options = evaluator.evaluate(options); - let Some(transform) = options.prop("transform") else { continue }; - let Some(input) = inputs.iter_mut().find(|i| i.class_property_name == name.as_ref()) else { - continue; - }; - if let Some(expr) = transform_expression(allocator, transform, source_text, consts) { - input.transform_function = Some(expr); - } - } -} - /// ngtsc's `{...fromMeta, ...fromFields}` keyed by class property name: a member /// declaration replaces the metadata entry in place, new members are appended. /// The result is ordered like the keys of a JavaScript object: integer-like @@ -1102,39 +1070,13 @@ pub fn decorator_io_errors<'a>( _ => return None, }; let name = key.static_name()?; - // `@Input({ transform })` - let options = super::property_decorators::input_decorator_options(decorators, consts); - if let Some(options) = options { - let span = options.span(); - let options = evaluator.evaluate(options); - // ngtsc reads imported options (or an imported alias or - // `required`) from their file; oxc can't, and compiling the - // input without them would be a different binding. - let imported = std::iter::once(&options) - .chain( - ["alias", "required"].iter().filter_map(|k| Some(&options.prop(k)?.value)), - ) - .find(|value| value.is_import()); - if let Some(value) = imported { - return Some((value_error("@Input", String::new, value), span)); - } - if let Some(transform) = options.prop("transform") { - let error = - transform_error(transform, None, &name, class, consts.scope(), span) - .or_else(|| match transform { - Prop { expr: Some(written), .. } - if is_out_of_scope(transform, consts) => - { - let message = - scoped_transform_error("@Input", &name, written, consts); - Some((message, span)) - } - _ => None, - }); - if error.is_some() { - return error; - } - } + // `@Input(...)`, as ngtsc's `tryParseInputFieldMapping` reads it. + let decorator = + super::property_decorators::member_decorator(decorators, "Input", consts); + let error = + decorator.and_then(|d| input_decorator_error(d, &name, class, consts, &evaluator)); + if error.is_some() { + return error; } // A signal input only collides with a metadata entry of the same name. let value = value.filter(|_| meta_inputs.contains(&name.as_ref()))?; @@ -1149,6 +1091,19 @@ pub fn decorator_io_errors<'a>( }; let output_members = || { class.body.body.iter().find_map(|element| { + // `@Output(...)`, as ngtsc's `tryParseDecoratorOutput` reads it, on + // the members an output is compiled from. + let decorators = match element { + ClassElement::PropertyDefinition(p) => Some(&p.decorators), + ClassElement::AccessorProperty(p) => Some(&p.decorators), + _ => None, + }; + let decorator = decorators.and_then(|decorators| { + super::property_decorators::member_decorator(decorators, "Output", consts) + }); + if let Some(error) = decorator.and_then(|d| output_decorator_error(d, &evaluator)) { + return Some(error); + } let ClassElement::PropertyDefinition(prop) = element else { return None }; let (value, name) = (prop.value.as_ref()?, prop.key.static_name()?); if !meta_outputs.contains(&name.as_ref()) { @@ -1196,6 +1151,90 @@ pub fn decorator_io_errors<'a>( .collect() } +/// ngtsc's error for the `@Input(...)` decorator of the member `name` +/// (`tryParseInputFieldMapping`): more than one argument, an argument that +/// isn't `null`, a string or an object, or a `transform` it can't use. Options +/// from another module are reported as such (see [`value_error`]). +fn input_decorator_error<'a>( + decorator: &'a Decorator<'a>, + name: &str, + class: &'a Class<'a>, + consts: &StringConsts<'a>, + evaluator: &Evaluator<'_, 'a>, +) -> Option<(String, Span)> { + // ngtsc names it as it's written: `@In` for `import { Input as In }`. + let subject = format!("@{}", super::property_decorators::decorator_written_name(decorator)); + if let Some(error) = decorator_arity_error(decorator, &subject) { + return Some(error); + } + let options = super::property_decorators::decorator_argument(decorator)?; + let span = options.span(); + let options = evaluator.evaluate(options); + // ngtsc reads imported options (or an imported alias or `required`) from + // their file; oxc can't, and compiling the input without them would be a + // different binding. + let imported = std::iter::once(&options) + .chain(["alias", "required"].iter().filter_map(|k| Some(&options.prop(k)?.value))) + .find(|value| value.is_import()); + if let Some(value) = imported { + return Some((value_error(&subject, String::new, value), span)); + } + if !matches!(options, Value::Null | Value::String(_) | Value::Object(_)) { + let message = format!( + "{subject} decorator argument must resolve to a string or an object literal{}", + options.wrong_type_suffix() + ); + return Some((message, decorator.span)); + } + let transform = options.prop("transform")?; + transform_error(transform, None, name, class, consts.scope(), span).or_else( + || match transform { + Prop { expr: Some(written), .. } if is_out_of_scope(transform, consts) => { + Some((scoped_transform_error("@Input", name, written, consts), span)) + } + _ => None, + }, + ) +} + +/// ngtsc's error for a member decorator called with more than one argument, +/// named `subject` (`@Input`, or `@In` for `import { Input as In }`). +fn decorator_arity_error(decorator: &Decorator<'_>, subject: &str) -> Option<(String, Span)> { + let Expression::CallExpression(call) = &decorator.expression else { return None }; + let count = call.arguments.len(); + (count > 1).then(|| { + let message = format!("{subject} can have at most one argument, got {count} argument(s)"); + (message, decorator.span) + }) +} + +/// ngtsc's error for an `@Output(...)` decorator (`tryParseDecoratorOutput`): +/// more than one argument, or an argument that isn't a string. An argument +/// from another module is reported as such (see [`value_error`]). +fn output_decorator_error<'a>( + decorator: &'a Decorator<'a>, + evaluator: &Evaluator<'_, 'a>, +) -> Option<(String, Span)> { + // ngtsc names it `@Output` whatever it's imported as. + if let Some(error) = decorator_arity_error(decorator, "@Output") { + return Some(error); + } + let argument = super::property_decorators::decorator_argument(decorator)?; + match evaluator.evaluate(argument) { + Value::String(_) => None, + value if value.is_import() => { + Some((value_error("@Output", String::new, &value), argument.span())) + } + value => { + let message = format!( + "@Output decorator argument must resolve to a string{}", + value.wrong_type_suffix() + ); + Some((message, decorator.span)) + } + } +} + /// An initializer API: its function name and the module exporting it. pub(crate) type InitializerApi = (&'static str, &'static str); pub(crate) const INPUT_API: InitializerApi = ("input", "@angular/core"); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index eb955babe..14bbf0a55 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -38,9 +38,7 @@ pub use decorator::{ pub(crate) use decorator::{ angular_decorator_config, extract_string_value, resolve_template_literal, }; -pub(crate) use decorator::{ - merge_by_class_property, parse_decorator_io, resolve_member_transforms, -}; +pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub(crate) use dts_type::quote as ts_string_literal; pub use evaluator::input_transform_types; diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index b8c25d9b2..5191a1284 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -19,6 +19,7 @@ use oxc_ast::ast::{ use oxc_span::{GetSpan, Span}; use oxc_str::Ident; +use super::evaluator::{Evaluator, Value}; use super::metadata::{QueryPredicate, R3InputMetadata, R3QueryMetadata}; use crate::output::ast::OutputExpression; use crate::output::oxc_converter::convert_oxc_expression; @@ -103,12 +104,43 @@ pub(crate) fn input_decorator_options<'a>( decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, consts: &super::StringConsts<'_>, ) -> Option<&'a Expression<'a>> { - match &find_decorator_by_name(decorators, "Input", Some(consts))?.expression { + decorator_argument(find_decorator_by_name(decorators, "Input", Some(consts))?) +} + +/// The first argument of a decorator call (`NAME` in `@Input(NAME)`), if any. +pub(crate) fn decorator_argument<'a>(decorator: &'a Decorator<'a>) -> Option<&'a Expression<'a>> { + match &decorator.expression { Expression::CallExpression(call) => call.arguments.first()?.as_expression(), _ => None, } } +/// One of Angular's member decorators (`name`: `Input`, `Output`, ...) in +/// `decorators`, as the file `consts` was collected from imports it (see +/// [`angular_member_decorator`]). +pub(crate) fn member_decorator<'a>( + decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, + name: &str, + consts: &super::StringConsts<'_>, +) -> Option<&'a Decorator<'a>> { + find_decorator_by_name(decorators, name, Some(consts)) +} + +/// The name ngtsc gives a decorator in its messages (`Decorator.name`): the +/// identifier it's called by, so `In` for `@In()` with +/// `import { Input as In }`, and `Input` for `@core.Input()`. +pub(crate) fn decorator_written_name<'a>(decorator: &'a Decorator<'a>) -> &'a str { + let callee = match &decorator.expression { + Expression::CallExpression(call) => &call.callee, + expr => expr, + }; + match callee { + Expression::Identifier(id) => id.name.as_str(), + Expression::StaticMemberExpression(m) => m.property.name.as_str(), + _ => "", + } +} + /// Get the property key name as an Atom. /// /// Handles both identifier keys and string literal keys. @@ -216,10 +248,53 @@ impl<'a> Default for InputConfig<'a> { /// - `@Input()` - no configuration /// - `@Input('alias')` - string alias /// - `@Input({ alias: 'name', required: true, transform: fn })` - full config +/// +/// With the file (`file`), the argument is read through the evaluator, like +/// ngtsc's `tryParseInputFieldMapping`: a string (`NAME`, `` `y` ``, +/// `'a' + 'b'`) is the alias, and an object (`OPTS`, `{...OPTS, required: true}`) +/// gives `alias` (when it's a string), `required` (when it's `true`) and +/// `transform` (the expression ngtsc emits for it, see +/// [`super::decorator::transform_expression`]). Anything else is `null` or an +/// error [`super::decorator_io_errors`] reports, and gives no options. Without +/// the file (the public `extract_*` functions), only literals are read. fn parse_input_config<'a>( allocator: &'a Allocator, decorator: &'a Decorator<'a>, source_text: Option<&'a str>, + file: Option<(&Evaluator<'_, 'a>, &super::StringConsts<'a>)>, +) -> InputConfig<'a> { + let literal = parse_literal_input_config(allocator, decorator, source_text); + let (Some((evaluator, consts)), Some(argument)) = (file, decorator_argument(decorator)) else { + return literal; + }; + let alloc = |s: &str| Ident::from(allocator.alloc_str(s)); + let options = evaluator.evaluate(argument); + match &options { + Value::String(alias) => InputConfig { alias: Some(alloc(alias)), ..Default::default() }, + Value::Object(_) => InputConfig { + alias: options.prop("alias").and_then(|p| p.value.as_str()).map(alloc), + required: matches!(options.prop("required").map(|p| &p.value), Some(Value::Bool(true))), + transform: options + .prop("transform") + .and_then(|transform| { + super::decorator::transform_expression( + allocator, + transform, + source_text, + consts, + ) + }) + .or(literal.transform), + }, + _ => InputConfig::default(), + } +} + +/// [`parse_input_config`] for the literals written in the decorator. +fn parse_literal_input_config<'a>( + allocator: &'a Allocator, + decorator: &'a Decorator<'a>, + source_text: Option<&'a str>, ) -> InputConfig<'a> { let Expression::CallExpression(call) = &decorator.expression else { return InputConfig::default(); @@ -455,7 +530,9 @@ pub fn extract_input_metadata<'a>( /// `@angular/core` (see [`is_core_namespace`]). With `consts`, a signal member /// counts only when it calls Angular's `input()` / `model()`, as ngtsc's /// `tryParseInitializerApi` sees it (see [`super::decorator::initializer_api`]); -/// without, any function with that name does. +/// without, any function with that name does. With `consts`, an `@Input(...)` +/// argument is also evaluated like ngtsc does (`@Input(NAME)`, `@Input(OPTS)`, +/// see [`parse_input_config`]); without, only literals are read. pub fn extract_input_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, @@ -463,6 +540,8 @@ pub fn extract_input_metadata_in<'a>( consts: Option<&super::StringConsts<'a>>, ) -> Vec<'a, R3InputMetadata<'a>> { let mut inputs = Vec::new_in(&allocator); + let evaluator = consts.map(Evaluator::new); + let file = evaluator.as_ref().zip(consts); for element in &class.body.body { match element { @@ -473,7 +552,7 @@ pub fn extract_input_metadata_in<'a>( continue; }; - let config = parse_input_config(allocator, decorator, source_text); + let config = parse_input_config(allocator, decorator, source_text, file); let binding_property_name = config.alias.unwrap_or_else(|| class_property_name.clone()); @@ -515,7 +594,7 @@ pub fn extract_input_metadata_in<'a>( continue; }; - let config = parse_input_config(allocator, decorator, source_text); + let config = parse_input_config(allocator, decorator, source_text, file); let binding_property_name = config.alias.unwrap_or_else(|| class_property_name.clone()); @@ -540,7 +619,7 @@ pub fn extract_input_metadata_in<'a>( continue; }; - let config = parse_input_config(allocator, decorator, source_text); + let config = parse_input_config(allocator, decorator, source_text, file); let binding_property_name = config.alias.unwrap_or_else(|| class_property_name.clone()); @@ -582,20 +661,30 @@ impl<'a> Default for OutputConfig<'a> { /// Handles these variants: /// - `@Output()` - no configuration /// - `@Output('alias')` - string alias -fn parse_output_config<'a>(decorator: &'a Decorator<'a>) -> OutputConfig<'a> { - let Expression::CallExpression(call) = &decorator.expression else { +/// +/// With the file's `evaluator`, the argument is read through it, like ngtsc's +/// `tryParseDecoratorOutput`: a string (`NAME`, `` `y` ``, `'a' + 'b'`) is the +/// alias; anything else is an error [`super::decorator_io_errors`] reports. +/// Without it (the public `extract_*` functions), only a string literal is read. +fn parse_output_config<'a>( + allocator: &'a Allocator, + decorator: &'a Decorator<'a>, + evaluator: Option<&Evaluator<'_, 'a>>, +) -> OutputConfig<'a> { + let Some(argument) = decorator_argument(decorator) else { return OutputConfig::default(); }; - - let Some(first_arg) = call.arguments.first() else { - return OutputConfig::default(); + let alias = match evaluator { + Some(evaluator) => match evaluator.evaluate(argument) { + Value::String(alias) => Some(Ident::from(allocator.alloc_str(&alias))), + _ => None, + }, + None => match argument { + Expression::StringLiteral(lit) => Some(lit.value.clone().into()), + _ => None, + }, }; - - match first_arg { - // @Output('alias') - Argument::StringLiteral(lit) => OutputConfig { alias: Some(lit.value.clone().into()) }, - _ => OutputConfig::default(), - } + OutputConfig { alias } } /// Extract @Output metadata from all properties in a class. @@ -623,13 +712,16 @@ pub fn extract_output_metadata<'a>( /// `@angular/core` (see [`is_core_namespace`]). With `consts`, a signal member /// counts only when it calls Angular's `output()` / `outputFromObservable()` / /// `model()` (see [`super::decorator::initializer_api`]); without, any -/// function with that name does. +/// function with that name does. With `consts`, an `@Output(...)` argument is +/// also evaluated like ngtsc does (`@Output(NAME)`, see +/// [`parse_output_config`]); without, only a string literal is read. pub fn extract_output_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, consts: Option<&super::StringConsts<'a>>, ) -> Vec<'a, (Ident<'a>, Ident<'a>)> { let mut outputs = Vec::new_in(&allocator); + let evaluator = consts.map(Evaluator::new); for element in &class.body.body { match element { @@ -641,7 +733,7 @@ pub fn extract_output_metadata_in<'a>( continue; }; - let config = parse_output_config(decorator); + let config = parse_output_config(allocator, decorator, evaluator.as_ref()); let binding_property_name = config.alias.unwrap_or_else(|| class_property_name.clone()); @@ -677,7 +769,7 @@ pub fn extract_output_metadata_in<'a>( continue; }; - let config = parse_output_config(decorator); + let config = parse_output_config(allocator, decorator, evaluator.as_ref()); let binding_property_name = config.alias.unwrap_or_else(|| class_property_name.clone()); diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 61c9d48b1..352bb6c52 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 878, "fixtures compared"); + assert_eq!(compared, 967, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -460,6 +460,43 @@ export class Dir { assert!(strip(&result.code).contains(r#"inputs:{x:[2,"y","x",fn]}"#), "{}", result.code); } +/// The same for an `@Output(...)` alias: ngtsc 22.1.7 reads it from `./shared` +/// (with `export const NAME = 'y'`, it compiles the output as `y`). oxc can't, +/// so it reports it instead of compiling the output under its property name. +#[test] +fn output_decorator_alias_imported_from_another_module_is_reported() { + let cases = [ + ("@Output(NAME) e = new EventEmitter();", "NAME", "NAME"), + ("@Output((NAME)) e = new EventEmitter();", "NAME", "(NAME)"), + ("@Output(ns.NAME) e = new EventEmitter();", "NAME", "ns.NAME"), + ("@Output(LOCAL) e = new EventEmitter();", "NAME", "LOCAL"), + ("@Output(`${NAME}`) e = new EventEmitter();", "NAME", "`${NAME}`"), + ("@Output(NAME) accessor e = new EventEmitter();", "NAME", "NAME"), + ]; + for (member, name, span) in cases { + let source = format!( + "import {{Directive, EventEmitter, Output}} from '@angular/core'; +import {{NAME}} from './shared'; +import * as ns from './shared'; +const LOCAL = NAME; +@Directive({{selector: '[d]'}}) +export class Dir {{ + {member} +}} +" + ); + let message = format!( + "@Output depends on '{name}', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files." + ); + assert_eq!( + errors(&transform(&source), &source), + vec![(message, span.to_string())], + "{member}" + ); + } +} + /// A transform returned by a function the metadata calls is emitted where the /// directive is compiled, outside that function. A parameter becomes the /// argument it was passed (the snapshot's `scope-*` probes), and a reference diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 522ba8f39..94d7f45da 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -10884,6 +10884,1310 @@ "classes": {}, "dtsImports": [] }, + { + "name": "probe: memberArg-in-constString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-letString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nlet NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-varString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nvar NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-typedConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME: string = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-template", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(`y`) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-templateExpr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst P = 'p';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(`${P}y`) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"py\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-concat", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input('a' + 'b') x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"ab\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y'};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constObjectRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y', required: true};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constObjectTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y', transform: booleanAttribute};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"y\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-spread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y'};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({...OPTS}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-spreadRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y'};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({...OPTS, required: true}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-spreadOverride", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y', required: true};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({...OPTS, alias: 'z'}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"z\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-parens", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input((NAME)) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-asConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y' as const;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-asAny", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME as any) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-nonNull", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME!) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-nestedConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst A = 'y';\nconst B = A;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(B) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-objectMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst N = {x: 'y'};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(N.x) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-arrayElement", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst N = ['a', 'y'];\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(N[1]) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: NAME}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasTemplate", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: `y`}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-requiredConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst R = true;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({required: R}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-enumString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nenum E { A = 'y' }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(E.A) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'E'." + ], + "diagnosticSpans": [[169, 180]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-enumNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nenum E { A }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(E.A) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'E'." + ], + "diagnosticSpans": [[163, 174]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constEnumString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst enum E { A = 'y' }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(E.A) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'E'." + ], + "diagnosticSpans": [[175, 186]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-number", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(1 as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[150, 166]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-bool", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(true as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'boolean'." + ], + "diagnosticSpans": [[150, 169]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-null", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(null) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-undefined", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(undefined) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'undefined'." + ], + "diagnosticSpans": [[150, 167]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-array", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(['y'] as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type '[string]'." + ], + "diagnosticSpans": [[150, 170]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst N = 1;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(N as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[163, 179]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-declared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\ndeclare const N: string;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(N) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is a reference to 'N'." + ], + "diagnosticSpans": [[175, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-call", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nfunction f(): string { return 'y'; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(f()) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-twoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @Input('a', 'b') x: any;\n}\n" + }, + "diagnostics": ["@Input can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[166, 182]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-twoArgsConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @Input(NAME, NAME) x: any;\n}\n" + }, + "diagnostics": ["@Input can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[184, 202]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: 1 as any}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasConstNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 1};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS as any) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-requiredString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({required: 'yes' as any}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-setter", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) set x(v: any) {}\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-setterObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y', required: true};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) set x(v: any) {}\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-accessor", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) accessor x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-component", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Component({selector: 'c', template: ''})\nexport class Cmp {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Cmp": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-componentObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y', required: true};\n@Component({selector: 'c', template: ''})\nexport class Cmp {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Cmp": { + "inputs": "{x:[0,\"y\",\"x\"]}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-numberThenOutput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(1 as any) x: any;\n @Output(2 as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[150, 166]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-badAndMetaInputs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [1 as any]})\nexport class Dir {\n @Input(1 as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is of type 'number'." + ], + "diagnosticSpans": [[136, 146]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-constString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(NAME) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-letString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nlet NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(NAME) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-template", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(`y`) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-templateExpr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst P = 'p';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(`${P}y`) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"py\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-concat", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output('a' + 'b') e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"ab\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-parens", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output((NAME)) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-asConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y' as const;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(NAME) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-nestedConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst A = 'y';\nconst B = A;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(B) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-enumString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nenum E { A = 'y' }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(E.A) e = new EventEmitter();\n}\n" + }, + "diagnostics": ["@Output decorator argument must resolve to a string Value is of type 'E'."], + "diagnosticSpans": [[169, 181]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-enumNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nenum E { A }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(E.A as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": ["@Output decorator argument must resolve to a string Value is of type 'E'."], + "diagnosticSpans": [[163, 182]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-number", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(1 as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'number'." + ], + "diagnosticSpans": [[150, 167]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-null", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(null) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'null'." + ], + "diagnosticSpans": [[150, 163]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-undefined", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(undefined) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'undefined'." + ], + "diagnosticSpans": [[150, 168]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-object", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output({alias: 'y'} as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type '{ alias: string }'." + ], + "diagnosticSpans": [[150, 178]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-constObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {alias: 'y'};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(OPTS as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type '{ alias: string }'." + ], + "diagnosticSpans": [[177, 197]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-declared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\ndeclare const N: string;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(N) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is a reference to 'N'." + ], + "diagnosticSpans": [[175, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-twoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @Output('a', 'b') e = new EventEmitter();\n}\n" + }, + "diagnostics": ["@Output can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[166, 183]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-noArg", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"e\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-component", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Component({selector: 'c', template: ''})\nexport class Cmp {\n @Output(NAME) e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Cmp": { + "outputs": "{e:\"y\"}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-accessor", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(NAME) accessor e = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"y\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-badAndMetaOutputs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', outputs: [1 as any]})\nexport class Dir {\n @Output(1 as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "Failed to resolve outputs at position 0 to a string Value is of type 'number'." + ], + "diagnosticSpans": [[137, 147]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasedTwoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input as In} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @In('a', 'b') x: any;\n}\n" + }, + "diagnostics": ["@In can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[121, 134]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasedNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input as In} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @In(1 as any) x: any;\n}\n" + }, + "diagnostics": [ + "@In decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[105, 118]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-nsTwoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport * as core from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @core.Input('a', 'b') x: any;\n}\n" + }, + "diagnostics": ["@Input can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[147, 168]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-nsNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport * as core from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @core.Input(1 as any) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[131, 152]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-aliasedNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, EventEmitter, Output as Out} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Out(1 as any) e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'number'." + ], + "diagnosticSpans": [[121, 135]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-aliasedTwoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, EventEmitter, Output as Out} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @Out('a', 'b') e = new EventEmitter();\n}\n" + }, + "diagnostics": ["@Output can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[137, 151]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-letUninit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nlet NAME;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'undefined'." + ], + "diagnosticSpans": [[149, 161]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-aliasLetUninit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nlet NAME;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: NAME}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constArrowTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {transform: (v: string) => 1};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constFnTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nexport function f(v: string) { return 1; }\nconst OPTS = {alias: 'y', transform: f};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"y\",\"x\",f]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constBadTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {transform: 1};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'number'."], + "diagnosticSpans": [[175, 179]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-twoArgsAndBadFirst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n // @ts-ignore\n @Input(1, 2) x: any;\n}\n" + }, + "diagnostics": ["@Input can have at most one argument, got 2 argument(s)"], + "diagnosticSpans": [[155, 167]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-emptyObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-emptyString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input('') x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-constEmpty", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-ternary", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst C = false;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(C ? 'a' : 'b') x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"b\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-orDefault", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst N = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(N || 'z') x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"z\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-transformOnlyConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst OPTS = {transform: booleanAttribute};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(OPTS) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-transformConstAlias", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: NAME, transform: booleanAttribute}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"y\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-staticNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(1 as any) static x: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[139, 155]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-out-staticNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(1 as any) static e = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'number'." + ], + "diagnosticSpans": [[139, 156]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: memberArg-in-twoInputsSecondBad", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter, booleanAttribute} from '@angular/core';\nconst NAME = 'y';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NAME) a: any;\n @Input(1 as any) b: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[180, 196]], + "classes": {}, + "dtsImports": [] + }, { "name": "probe: transform-global-atob", "origin": "probe", diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 2a06f4113..4b2a4d684 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -105,3 +105,42 @@ export class X { expect(component.queries ?? []).toEqual([]) }) }) + +// Like ngtsc, an `@Input(...)` / `@Output(...)` argument is evaluated, so a +// same-file const gives the alias, `required` flag and transform. +describe('extractComponentMetadataSync member decorator arguments', () => { + it('reads aliases and options through same-file consts', () => { + const [component] = extractComponentMetadataSync( + ` +import { Component, EventEmitter, Input, Output, booleanAttribute } from '@angular/core'; +const NAME = 'y'; +const OPTS = { alias: \`z\`, required: true, transform: booleanAttribute }; +const t = booleanAttribute; + +@Component({ selector: 'app-x', template: '' }) +export class X { + @Input(NAME) a: any; + @Input(OPTS) b: any; + @Input({ ...OPTS, alias: 'w', transform: t }) c: any; + @Output('e' + NAME) d = new EventEmitter(); +} +`, + 'x.component.ts', + ) + expect( + component.inputs?.map((i) => [ + i.classPropertyName, + i.bindingPropertyName, + i.required, + i.transform ?? null, + ]), + ).toEqual([ + ['a', 'y', false, null], + ['b', 'z', true, 'booleanAttribute'], + ['c', 'w', true, 'booleanAttribute'], + ]) + expect(component.outputs?.map((o) => [o.classPropertyName, o.bindingPropertyName])).toEqual([ + ['d', 'ey'], + ]) + }) +}) From 306a190ba9ca8cf011eaff8d1e9b05af23691fe7 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 02:26:12 +0800 Subject: [PATCH 04/12] fix(directive): emit a global transform declared outside the file by its name A global like the DOM's `atob` is now assumed to be a transform function (see the change below in the stack). Reached through a variable, a helper, an object or a condition (`const t = atob` and `transform: t`), it was emitted as written (`t`), where ngtsc emits `atob`. It now gets the same reference identity as an ES lib global like `parseInt`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/decorator.rs | 5 +- .../tests/decorator_metadata_ngtsc_test.rs | 2 +- .../fixtures/decorator_metadata_ngtsc.json | 112 ++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index dc32df88f..ca815e90e 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -750,7 +750,8 @@ fn is_out_of_scope(transform: &Prop<'_>, consts: &StringConsts<'_>) -> bool { /// The identifier ngtsc emits for a transform that resolves to a reference /// (the reference's identity in this file, `getIdentityIn`), see /// [`transform_expression`]: a same-file function's name, or the name an -/// imported function or a global was first reached by, through any variables +/// imported function or a global (including one declared outside the file, +/// like `atob`) was first reached by, through any variables /// (`booleanAttribute` for `const t = booleanAttribute` and `transform: t`). fn reference_identity<'p>(transform: &'p Prop<'_>, consts: &StringConsts<'_>) -> Option<&'p str> { match &transform.value { @@ -760,7 +761,7 @@ fn reference_identity<'p>(transform: &'p Prop<'_>, consts: &StringConsts<'_>) -> Some(name) } Value::Reference { kind: RefKind::Import { namespace_member: false, local }, .. } => *local, - Value::Reference { name, kind: RefKind::Global } => Some(name), + Value::Reference { name, kind: RefKind::Global | RefKind::Ambient } => Some(name), _ => None, } } diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 352bb6c52..6bd486003 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 967, "fixtures compared"); + assert_eq!(compared, 971, "fixtures compared"); } fn transform(source: &str) -> TransformResult { diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 94d7f45da..53d4ac1d1 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -12735,6 +12735,118 @@ "classes": {}, "dtsImports": [] }, + { + "name": "probe: shorthandRef-globalConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nconst t = atob;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: t}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalTernary", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nconst A = 1;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: A ? atob : btoa}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalHelper", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nfunction pick(f: (v: string) => string) { return f; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: pick(atob)}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalObject", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nconst T = {f: atob};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: T.f}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shorthandRef-globalCondition", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: window ? atob : btoa}) x: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "skip": "a global declared outside the file is dynamic in oxc wherever its value is used (here in a condition or `||`), since oxc can't tell it from a name declared nowhere, so the transform isn't a function; ngtsc reads its declaration and picks `atob`" + }, + { + "name": "probe: shorthandRef-globalOr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: atob || btoa}) x: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "skip": "a global declared outside the file is dynamic in oxc wherever its value is used (here in a condition or `||`), since oxc can't tell it from a name declared nowhere, so the transform isn't a function; ngtsc reads its declaration and picks `atob`" + }, + { + "name": "probe: shorthandRef-globalNot", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (!atob ? btoa : atob)}) x: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "skip": "a global declared outside the file is dynamic in oxc wherever its value is used (here in a condition or `||`), since oxc can't tell it from a name declared nowhere, so the transform isn't a function; ngtsc reads its declaration and picks `atob`" + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From 41c24d10b61dfaef36f9c581e38c5e6c2ef91f57 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 02:27:03 +0800 Subject: [PATCH 05/12] fix(directive): reject `@Input` / `@Output` on signal inputs, models and outputs like ngtsc `@Input() value = input(0)` compiled as a plain input with no error; ngtsc reports "Using @Input with a signal input is not allowed." oxc only looked at the initializer of a member also listed in `inputs:` (for a different error), and had no such check. The same went for `@Input` on `model()`, and `@Output` on `output()`, `outputFromObservable()` or `model()`. Each is now reported on the decorator with ngtsc's message, in its order: an input's check comes before its `@Input` arguments are read, an output's after its `@Output` arguments. The call counts only when it's Angular's API by its import (any alias, a namespace, `.required`), so a local or foreign `input()` is left alone. The compiled output is unchanged, as for other errors. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/decorator.rs | 37 +- .../tests/decorator_metadata_ngtsc_test.rs | 2 +- .../fixtures/decorator_metadata_ngtsc.json | 526 ++++++++++++++++++ 3 files changed, 560 insertions(+), 5 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index ca815e90e..21ccf7c01 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -1074,6 +1074,20 @@ pub fn decorator_io_errors<'a>( // `@Input(...)`, as ngtsc's `tryParseInputFieldMapping` reads it. let decorator = super::property_decorators::member_decorator(decorators, "Input", consts); + // ngtsc rejects `@Input` on a signal input or model before reading + // the decorator. + if let (Some(decorator), Some(value)) = (decorator, value) { + let message = if is_initializer_api_call(value, consts, &[INPUT_API]) { + Some("Using @Input with a signal input is not allowed.") + } else if is_initializer_api_call(value, consts, &[MODEL_API]) { + Some("Using @Input with a model input is not allowed.") + } else { + None + }; + if let Some(message) = message { + return Some((message.to_string(), decorator.span)); + } + } let error = decorator.and_then(|d| input_decorator_error(d, &name, class, consts, &evaluator)); if error.is_some() { @@ -1094,10 +1108,10 @@ pub fn decorator_io_errors<'a>( class.body.body.iter().find_map(|element| { // `@Output(...)`, as ngtsc's `tryParseDecoratorOutput` reads it, on // the members an output is compiled from. - let decorators = match element { - ClassElement::PropertyDefinition(p) => Some(&p.decorators), - ClassElement::AccessorProperty(p) => Some(&p.decorators), - _ => None, + let (decorators, value) = match element { + ClassElement::PropertyDefinition(p) => (Some(&p.decorators), p.value.as_ref()), + ClassElement::AccessorProperty(p) => (Some(&p.decorators), p.value.as_ref()), + _ => (None, None), }; let decorator = decorators.and_then(|decorators| { super::property_decorators::member_decorator(decorators, "Output", consts) @@ -1105,6 +1119,21 @@ pub fn decorator_io_errors<'a>( if let Some(error) = decorator.and_then(|d| output_decorator_error(d, &evaluator)) { return Some(error); } + // Then `@Output` on an `output()` or a model, like ngtsc's + // `parseOutputFields`. + if let (Some(decorator), Some(value)) = (decorator, value) { + let apis = [OUTPUT_API, OUTPUT_FROM_OBSERVABLE_API]; + let message = if is_initializer_api_call(value, consts, &apis) { + Some("Using \"@Output\" with \"output()\" is not allowed.") + } else if is_initializer_api_call(value, consts, &[MODEL_API]) { + Some("Using @Output with a model input is not allowed.") + } else { + None + }; + if let Some(message) = message { + return Some((message.to_string(), decorator.span)); + } + } let ClassElement::PropertyDefinition(prop) = element else { return None }; let (value, name) = (prop.value.as_ref()?, prop.key.static_name()?); if !meta_outputs.contains(&name.as_ref()) { diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 6bd486003..b3cb08a2a 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 971, "fixtures compared"); + assert_eq!(compared, 1014, "fixtures compared"); } fn transform(source: &str) -> TransformResult { diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 53d4ac1d1..294de9034 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -12847,6 +12847,532 @@ "dtsImports": [], "skip": "a global declared outside the file is dynamic in oxc wherever its value is used (here in a condition or `||`), since oxc can't tell it from a name declared nowhere, so the transform isn't a function; ngtsc reads its declaration and picks `atob`" }, + { + "name": "probe: signalDecorator-in-direct", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-alias", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport {input as inp} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = inp(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[220, 228]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-ns", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport * as ng from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = ng.input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[213, 221]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-nsDecorator", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport * as ng from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @ng.Input() value = ng.input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[213, 224]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-required", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input.required();\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-nsRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport * as ng from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = ng.input.required();\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[213, 221]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-model", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = model(0);\n}\n" + }, + "diagnostics": ["Using @Input with a model input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-modelRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = model.required();\n}\n" + }, + "diagnostics": ["Using @Input with a model input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-local", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, model, output, ViewChild, viewChild} from '@angular/core';\nfunction input(v: number) { return v; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-undeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-aliasArg", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input('a') value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 187]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-badArg", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(1 as any) value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 192]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-twoArgs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input('a', 'b' as any) value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 199]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-afterBad", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(1 as any) a: any;\n @Input() value = input(0);\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is of type 'number'." + ], + "diagnosticSpans": [[176, 192]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-beforeBad", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0);\n @Input(1 as any) a: any;\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-meta", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]', inputs: ['value']})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[195, 203]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-metaOther", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]', inputs: ['other']})\nexport class Dir {\n @Input() value = input(0);\n other = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[195, 203]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-metaBad", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]', inputs: [1 as any]})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is of type 'number'." + ], + "diagnosticSpans": [[162, 172]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-static", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() static value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-component", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Component({selector: 'c', template: ''})\nexport class Dir {\n @Input() value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[188, 196]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: string) => 1}) value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[176, 213]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-andOutput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() @Output() value = model(0);\n}\n" + }, + "diagnostics": ["Using @Input with a model input is not allowed."], + "diagnosticSpans": [[176, 184]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-outputOnInputMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() a = output();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:\"a\"}", + "outputs": "{a:\"a\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-direct", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = output();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[176, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-alias", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport {output as out} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = out();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[221, 230]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-ns", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport * as ng from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = ng.output();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[213, 222]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-fromObs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\nimport {outputFromObservable} from '@angular/core/rxjs-interop';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = outputFromObservable(new EventEmitter());\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[241, 250]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-model", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = model(0);\n}\n" + }, + "diagnostics": ["Using @Output with a model input is not allowed."], + "diagnosticSpans": [[176, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-modelRequired", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = model.required();\n}\n" + }, + "diagnostics": ["Using @Output with a model input is not allowed."], + "diagnosticSpans": [[176, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-badArg", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(1 as any) e = output();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'number'." + ], + "diagnosticSpans": [[176, 193]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-afterBad", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(1 as any) a = new EventEmitter();\n @Output() e = output();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value is of type 'number'." + ], + "diagnosticSpans": [[176, 193]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-meta", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]', outputs: ['e']})\nexport class Dir {\n @Output() e = output();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[192, 201]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-inputFirst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = output();\n @Input() value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[202, 210]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-static", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() static e = output();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[176, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-inputOnOutput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, output, ViewChild, viewChild} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = input(0);\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{e:[1,\"e\"]}", + "outputs": "{e:\"e\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-foreign", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, model, output, ViewChild, viewChild} from '@angular/core';\nimport {input} from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0);\n}\n", + "other.ts": "export function input(v: number) { return v; }\nexport function model(v: number) { return v; }\nexport function output() { return 1; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-foreignModel", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, output, ViewChild, viewChild} from '@angular/core';\nimport {model} from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = model(0);\n}\n", + "other.ts": "export function input(v: number) { return v; }\nexport function model(v: number) { return v; }\nexport function output() { return 1; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-foreign", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, EventEmitter, input, model, ViewChild, viewChild} from '@angular/core';\nimport {output} from './other';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() e = output();\n}\n", + "other.ts": "export function input(v: number) { return v; }\nexport function model(v: number) { return v; }\nexport function output() { return 1; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"e\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-accessor", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, input, model, output} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() accessor value = input(0);\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[129, 137]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-out-accessor", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, input, model, output} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output() accessor e = output();\n}\n" + }, + "diagnostics": ["Using \"@Output\" with \"output()\" is not allowed."], + "diagnosticSpans": [[129, 138]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-setter", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, input, model, output} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() set value(v: any) {}\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{value:\"value\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-parens", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, input, model, output} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = (input(0));\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[129, 137]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: signalDecorator-in-asAny", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, input, model, output} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input() value = input(0) as any;\n}\n" + }, + "diagnostics": ["Using @Input with a signal input is not allowed."], + "diagnosticSpans": [[129, 137]], + "classes": {}, + "dtsImports": [] + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From a56d8ec4871b57283419c2ba3d94db64f75c121b Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 03:02:47 +0800 Subject: [PATCH 06/12] test(directive): cover `&&` / `||` in `@Input` / `@Output` arguments and helper-returned transforms `@Input(LOCAL || NAME)`, `@Input({alias: LOCAL || NAME, required: true || FLAG})`, `@Output(LOCAL || NAME)` and `transform: 0 || booleanAttribute` now compile like ngtsc (the evaluator fix at the bottom of the stack). Also pins `@Input(opts({t: booleanAttribute}))`, which emits `booleanAttribute` through the reference identity this PR adds. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/decorator_metadata_ngtsc_test.rs | 2 +- .../fixtures/decorator_metadata_ngtsc.json | 340 ++++++++++++++++++ 2 files changed, 341 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index b3cb08a2a..b6c20b6ec 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 1014, "fixtures compared"); + assert_eq!(compared, 1034, "fixtures compared"); } fn transform(source: &str) -> TransformResult { diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 294de9034..7f1e4298e 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -13761,6 +13761,346 @@ }, "dtsImports": [] }, + { + "name": "probe: helperTransform-inputOpts", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nfunction opts(o: {t: typeof booleanAttribute}) { return {transform: o.t}; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(opts({t: booleanAttribute})) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-metaMake", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nfunction make(o: {t: any}) { return {name: 'x', transform: o.t}; }\n@Directive({selector: '[d]', inputs: [make({t: booleanAttribute})]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-inputOptsAliased", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {booleanAttribute as ba} from '@angular/core';\nfunction opts(o: {t: typeof booleanAttribute}) { return {transform: o.t}; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(opts({t: ba})) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ba]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-inputOptsLocal", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nfunction opts(o: {t: typeof booleanAttribute}) { return {transform: o.t}; }\nfunction local(v: string) { return true; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(opts({t: local})) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",local]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-inputOptsSpread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nfunction opts(o: {t: typeof booleanAttribute}) { return {transform: o.t}; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({...opts({t: booleanAttribute}), alias: 'a'}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"a\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-inputOptsConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nfunction opts(o: {t: typeof booleanAttribute}) { return {transform: o.t}; }\nconst O = opts({t: booleanAttribute});\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(O) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: helperTransform-inputDirectMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nconst T = {t: booleanAttribute};\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: T.t}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inOrLocal", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(LOCAL || NAME) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"localAlias\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inAndFalsy", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input((EMPTY_LOCAL && NAME) || 'z') x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"z\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inCondTrue", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(true ? LOCAL : NAME) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"localAlias\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inCondFalse", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(false ? NAME : LOCAL) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"localAlias\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inCoalesce", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(LOCAL ?? NAME) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value could not be determined statically." + ], + "diagnosticSpans": [[324, 345]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inObjAliasOr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: LOCAL || NAME}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"localAlias\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inObjRequiredOr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: 'a', required: true || FLAG}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"a\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inObjRequiredCond", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: 'a', required: 1 ? true : FLAG}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[0,\"a\",\"x\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inTransformOr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: 0 || booleanAttribute}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inTransformOrLocalFirst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\nfunction local(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: local || toBool}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",local]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-inTransformCondLocal", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\nfunction local(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: false ? toBool : local}) x: any;\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",local]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:string;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-outOr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(LOCAL || NAME) e = new EventEmitter();\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"localAlias\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: shortCircuit-outCond", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Component, Input, Output, ViewChild, ContentChild, ElementRef, booleanAttribute, EventEmitter} from '@angular/core';\nimport {NAME, FLAG, TRUE_FLAG, Token, EMPTY, toBool} from './other';\nconst LOCAL: string = 'localAlias';\nconst EMPTY_LOCAL: string = '';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(true ? LOCAL : NAME) e = new EventEmitter();\n}\n", + "other.ts": "import {booleanAttribute} from '@angular/core';\nexport const NAME = 'y';\nexport const EMPTY = '';\nexport const FLAG = false;\nexport const TRUE_FLAG = true;\nexport class Token {}\nexport function toBool(v: string) { return !!v; }\nexport interface Other { a: string }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{e:\"localAlias\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, { "name": "probe: scope-typeOnly-returnType", "origin": "probe", From 69dcc26c8cd7ee49bd6267018b427ce6bf668f9a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 04:36:14 +0800 Subject: [PATCH 07/12] test(directive): compare member `@Input` / `@Output` arguments read from namespaces with ngtsc Arguments of member `@Input(...)` / `@Output(...)` read through `typeof NS.X` or `import S = NS.X` (a name, or options with `alias` and `required`) now evaluate like ngtsc, `NS.X` in a value position stays dynamic, and a namespace is a reference to it. Add those cases to the ngtsc snapshot. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../tests/decorator_metadata_ngtsc_test.rs | 2 +- .../fixtures/decorator_metadata_ngtsc.json | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index b6c20b6ec..e97b545e3 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -307,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 1034, "fixtures compared"); + assert_eq!(compared, 1042, "fixtures compared"); } fn transform(source: &str) -> TransformResult { diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 7f1e4298e..06ef58f78 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -16173,6 +16173,125 @@ } }, "dtsImports": [] + }, + { + "name": "probe: r6-ns-member-input-alias-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export const AL = 'why'; }\ndeclare const S: typeof NS.AL;\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input(S) a: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"why\",\"a\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-output-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'changed'; }\ndeclare const S: typeof NS.AL;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(S) a = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "outputs": "{a:\"changed\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-input-options-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'why'; export const REQ = true; }\ndeclare const S: typeof NS.AL;\ndeclare const R: typeof NS.REQ;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: S, required: R}) a: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"why\",\"a\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-input-plain", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'why'; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NS.AL) a: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value could not be determined statically." + ], + "diagnosticSpans": [[163, 176]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-output-plain", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'why'; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Output(NS.AL) a = new EventEmitter();\n}\n" + }, + "diagnostics": [ + "@Output decorator argument must resolve to a string Value could not be determined statically." + ], + "diagnosticSpans": [[163, 177]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-input-alias-import", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'why'; }\nimport S = NS.AL;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(S) a: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"why\",\"a\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-input-namespace", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const AL = 'why'; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(NS) a: any;\n}\n" + }, + "diagnostics": [ + "@Input decorator argument must resolve to a string or an object literal Value is a reference to 'NS'." + ], + "diagnosticSpans": [[163, 173]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-m504-input-options-object-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, Output, EventEmitter} from '@angular/core';\nnamespace NS { export const OPTS = {alias: 'why'}; }\ndeclare const O: typeof NS.OPTS;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input(O) a: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"why\",\"a\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] } ] } From 5d11d26b4ffd7c2d683acbd515046cea1c5a46f4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 11:07:53 +0800 Subject: [PATCH 08/12] fix(compiler): recognise Angular class decorators by their @angular/core import, like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@Component`, `@Directive`, `@Pipe`, `@Injectable` and `@NgModule` were matched by name. Like ngtsc's `findAngularDecorator`, a class decorator is now Angular's only when it's imported from `@angular/core`: by name under any alias (`import {Component as Cmp}`), or through a namespace import (`@ng.Component()`). This is the rule #504 uses for member decorators, and the same check (`angular_core_decorator`). Checked against @angular/compiler-cli 22.1.7 (44 probes in tests/fixtures/class_decorators_ngtsc.json, 40 compared): - Another library's, a local, an undeclared or a default-imported `@Component` (and the other four) is left on the class, which isn't compiled, and its `inputs:` aren't checked. - One re-exported through another module (`import {Component} from './shared'`) is left alone too: ngtsc follows the re-export, oxc can't. - An aliased one (`@Cmp`, `@D`, `@P`, `@M`, `@Inj`) and a namespaced `@ng.Injectable` are now compiled; `setClassMetadata` lists them as written (`type: Cmp`). An aliased `@Component` keeps `templateUrl` in `setClassMetadata`, as ngtsc does. - `import {Injectable as Component}` compiles as an `@Injectable`. This holds for AOT compilation and decorator removal, setClassMetadata, the `@Service` collision check, JIT, the hoisting of declarations a decorator references, NAPI `extractComponentMetadataSync` / `extractComponentUrls` / the pipe and class-metadata APIs, and the Vite plugin: its quick decorator check lets `@Cmp(...)` / `@ng.Component(...)` files through, and the HMR template/styles diff only blanks the components the compiler compiled. The public `extract_pipe_metadata`, `extract_injectable_metadata`, `extract_ng_module_metadata` and `find_*_decorator_span`, which don't get the file's imports, still match by name (`extract_pipe_metadata_in` takes them). Not matched to ngtsc, on purpose: an aliased `@Injectable` gets a factory (ngtsc's `needsFactory` compares the written name with 'Injectable', so its `ɵprov` points at a missing `ɵfac`). 117 unit-test sources that used `@Component` / `@Directive` without importing it now import it from `@angular/core`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/decorator.rs | 132 +++-- .../src/component/hoist.rs | 81 +-- .../src/component/transform.rs | 345 ++++++------ .../src/directive/decorator.rs | 66 ++- .../src/directive/evaluator.rs | 2 +- .../oxc_angular_compiler/src/directive/mod.rs | 8 +- .../src/directive/property_decorators.rs | 57 +- .../src/injectable/decorator.rs | 31 +- .../src/injectable/mod.rs | 2 +- crates/oxc_angular_compiler/src/lib.rs | 7 +- .../src/ng_module/decorator.rs | 37 +- .../oxc_angular_compiler/src/ng_module/mod.rs | 2 +- .../src/pipe/decorator.rs | 39 +- crates/oxc_angular_compiler/src/pipe/mod.rs | 4 +- .../tests/class_decorator_identity_test.rs | 244 ++++++++ .../tests/class_metadata_resources_test.rs | 35 ++ .../fixtures/class_decorators_ngtsc.json | 520 ++++++++++++++++++ napi/angular-compiler/src/lib.rs | 62 ++- .../test/extract-component-metadata.test.ts | 25 + .../test/hmr-hot-update.test.ts | 39 ++ napi/angular-compiler/test/plugin.test.ts | 42 ++ napi/angular-compiler/vite-plugin/index.ts | 36 +- 22 files changed, 1479 insertions(+), 337 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs create mode 100644 crates/oxc_angular_compiler/tests/fixtures/class_decorators_ngtsc.json diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 168f571b8..054f07c69 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -59,8 +59,8 @@ pub fn extract_component_metadata<'a>( let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; - // Find the @Component decorator - let component_decorator = find_component_decorator(&class.decorators)?; + // Find Angular's @Component decorator + let component_decorator = find_component_decorator(&class.decorators, consts)?; // Get the decorator call arguments let call_expr = match &component_decorator.expression { @@ -68,11 +68,6 @@ pub fn extract_component_metadata<'a>( _ => return None, }; - // Verify it's calling 'Component' - if !is_component_call(&call_expr.callee) { - return None; - } - // Get the first argument (the config object) let config_arg = call_expr.arguments.first()?; let config_obj = match config_arg { @@ -339,33 +334,14 @@ fn populate_declarations_from_imports<'a>( } } -/// Find the @Component decorator in a list of decorators. -pub fn find_component_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { - decorators.iter().find(|d| match &d.expression { - Expression::CallExpression(call) => is_component_call(&call.callee), - Expression::Identifier(id) => id.name == "Component", - _ => false, - }) -} - -/// Find the span of the @Component decorator on a class. -/// -/// Returns the span including any leading whitespace/newlines that should be removed -/// along with the decorator. -pub fn find_component_decorator_span(class: &Class<'_>) -> Option { - find_component_decorator(&class.decorators).map(|d| d.span) -} - -/// Check if a callee expression is a call to 'Component'. -fn is_component_call(callee: &Expression<'_>) -> bool { - match callee { - Expression::Identifier(id) => id.name == "Component", - // Handle namespaced imports like ng.Component or core.Component - Expression::StaticMemberExpression(member) => { - matches!(&member.property.name.as_str(), &"Component") - } - _ => false, - } +/// Find Angular's @Component decorator (imported from `@angular/core`, in the +/// file `consts` was collected from) in a list of decorators: see +/// [`crate::directive::find_angular_class_decorator`]. +pub fn find_component_decorator<'a>( + decorators: &'a [Decorator<'a>], + consts: &StringConsts<'_>, +) -> Option<&'a Decorator<'a>> { + crate::directive::find_angular_class_decorator(decorators, "Component", consts) } /// Get the name of a property key as a string. @@ -1253,6 +1229,7 @@ mod tests { #[test] fn test_extract_selector() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test' }) class TestComponent {} "#; @@ -1264,6 +1241,7 @@ mod tests { #[test] fn test_extract_selector_with_attribute() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: '[appDirective]' }) class TestDirective {} "#; @@ -1275,6 +1253,7 @@ mod tests { #[test] fn test_extract_class_name() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'my-component' }) class MyAwesomeComponent {} "#; @@ -1290,6 +1269,7 @@ mod tests { #[test] fn test_extract_inline_template() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '

Hello World

' @@ -1305,6 +1285,7 @@ mod tests { #[test] fn test_extract_template_url() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', templateUrl: './test.component.html' @@ -1320,6 +1301,7 @@ mod tests { #[test] fn test_extract_template_with_backticks() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: ` @@ -1344,6 +1326,7 @@ mod tests { #[test] fn test_extract_standalone_true() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1359,6 +1342,7 @@ mod tests { #[test] fn test_extract_standalone_false() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1375,6 +1359,7 @@ mod tests { fn test_standalone_defaults_to_implicit_value_true() { // When standalone is not specified, it should use the implicit value let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -1392,6 +1377,7 @@ mod tests { fn test_standalone_defaults_to_implicit_value_false() { // When standalone is not specified, it should use the implicit value let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -1412,6 +1398,7 @@ mod tests { #[test] fn test_extract_encapsulation_none() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1427,6 +1414,7 @@ mod tests { #[test] fn test_extract_encapsulation_shadow_dom() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1442,6 +1430,7 @@ mod tests { #[test] fn test_extract_encapsulation_emulated() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1458,6 +1447,7 @@ mod tests { fn test_extract_encapsulation_numeric_none() { // Angular uses numeric values: Emulated=0, None=2, ShadowDom=3 let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1473,6 +1463,7 @@ mod tests { #[test] fn test_extract_encapsulation_numeric_shadow_dom() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1488,6 +1479,7 @@ mod tests { #[test] fn test_encapsulation_defaults_to_emulated() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -1506,6 +1498,7 @@ mod tests { #[test] fn test_extract_change_detection_on_push() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1523,6 +1516,7 @@ mod tests { // `Default` (value 1) is the pre-v22 spelling of `Eager`. It is kept as // a distinct variant so partial emit can preserve the author's member. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1539,6 +1533,7 @@ mod tests { fn test_extract_change_detection_numeric_eager() { // Angular v22 numeric values: OnPush = 0, Eager = 1. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1557,6 +1552,7 @@ mod tests { // `None` so the emitter applies the target version's default (OnPush in // v22+, Default/Eager before). let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -1575,6 +1571,7 @@ mod tests { #[test] fn test_extract_styles_array() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1593,6 +1590,7 @@ mod tests { fn test_extract_styles_single_string() { // Legacy support: styles can be a single string let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1609,6 +1607,7 @@ mod tests { #[test] fn test_extract_style_urls() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1627,6 +1626,7 @@ mod tests { fn test_extract_style_url_single() { // styleUrl (singular) support let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1647,6 +1647,7 @@ mod tests { #[test] fn test_extract_host_properties() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1666,6 +1667,7 @@ mod tests { #[test] fn test_extract_host_listeners() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1685,6 +1687,7 @@ mod tests { #[test] fn test_extract_host_static_attributes() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1704,6 +1707,7 @@ mod tests { #[test] fn test_extract_host_class_attr() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1722,6 +1726,7 @@ mod tests { #[test] fn test_extract_host_style_attr() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1744,6 +1749,7 @@ mod tests { #[test] fn test_extract_imports() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1766,6 +1772,7 @@ mod tests { #[test] fn test_extract_export_as() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1782,6 +1789,7 @@ mod tests { #[test] fn test_extract_export_as_multiple() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1800,6 +1808,7 @@ mod tests { #[test] fn test_extract_preserve_whitespaces_true() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1815,6 +1824,7 @@ mod tests { #[test] fn test_extract_preserve_whitespaces_false() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1830,6 +1840,7 @@ mod tests { #[test] fn test_preserve_whitespaces_defaults_to_false() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -1844,6 +1855,7 @@ mod tests { #[test] fn test_extract_schemas() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1883,6 +1895,7 @@ mod tests { #[test] fn test_empty_component_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({}) class TestComponent {} "#; @@ -1896,6 +1909,7 @@ mod tests { #[test] fn test_exported_class() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' }) export class TestComponent {} "#; @@ -1907,6 +1921,7 @@ mod tests { #[test] fn test_export_default_class() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' }) export default class TestComponent {} "#; @@ -1919,6 +1934,7 @@ mod tests { fn test_namespaced_component_decorator() { // Handle ng.Component or core.Component let code = r#" + import * as ng from '@angular/core'; @ng.Component({ selector: 'app-test', template: '' }) class TestComponent {} "#; @@ -1930,6 +1946,7 @@ mod tests { #[test] fn test_full_component_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-complete', template: '
{{title}}
', @@ -1976,6 +1993,7 @@ mod tests { #[test] fn test_extract_host_directives_simple() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -1995,6 +2013,7 @@ mod tests { #[test] fn test_extract_host_directives_multiple() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2013,6 +2032,7 @@ mod tests { #[test] fn test_extract_host_directives_object_form() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2031,6 +2051,7 @@ mod tests { #[test] fn test_extract_host_directives_with_input_mappings() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2056,6 +2077,7 @@ mod tests { #[test] fn test_extract_host_directives_with_output_mappings() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2079,6 +2101,7 @@ mod tests { #[test] fn test_extract_host_directives_with_input_output_mappings() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2102,6 +2125,7 @@ mod tests { #[test] fn test_extract_host_directives_same_name_mapping() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2126,6 +2150,7 @@ mod tests { #[test] fn test_extract_host_directives_tuple_array_mapping() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2150,6 +2175,7 @@ mod tests { #[test] fn test_extract_host_directives_forward_ref() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2167,6 +2193,7 @@ mod tests { #[test] fn test_extract_host_directives_forward_ref_in_object() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2190,6 +2217,7 @@ mod tests { #[test] fn test_extract_host_directives_empty() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2205,6 +2233,7 @@ mod tests { #[test] fn test_extract_host_directives_mixed() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2236,6 +2265,7 @@ mod tests { fn test_extract_host_directives_source_module_from_import() { // Test that host directive source_module is populated from imports let code = r#" + import {Component} from '@angular/core'; import { AriaDisableDirective } from "../a11y/aria-disable.directive"; import { FocusDirective, HighlightDirective } from "@angular/cdk/a11y"; @@ -2269,6 +2299,7 @@ mod tests { fn test_extract_host_directives_source_module_object_form() { // Test that source_module is populated in object form let code = r#" + import {Component} from '@angular/core'; import { ColorDirective } from "./directives/color"; @Component({ @@ -2294,6 +2325,7 @@ mod tests { fn test_extract_host_directives_no_source_module_for_local() { // Test that local directives (not imported) have no source_module let code = r#" + import {Component, Directive} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2316,6 +2348,7 @@ mod tests { fn test_extract_host_directives_mixed_imported_and_local() { // Test mixed imported and local directives let code = r#" + import {Component} from '@angular/core'; import { ImportedDirective } from "@angular/library"; @Component({ @@ -2528,6 +2561,7 @@ mod tests { #[test] fn test_no_host_decorators() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2553,6 +2587,7 @@ mod tests { // -> should use simple factory (Some with empty deps) // See: packages/compiler-cli/src/ngtsc/annotations/common/src/di.ts:47-52 let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2574,6 +2609,7 @@ mod tests { // -> should use inherited factory (None) // See: packages/compiler-cli/src/ngtsc/annotations/common/src/di.ts:47-52 let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2590,6 +2626,7 @@ mod tests { #[test] fn test_component_with_simple_constructor_deps() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-root', template: '
' @@ -2624,6 +2661,7 @@ mod tests { #[test] fn test_component_with_inject_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2654,6 +2692,7 @@ mod tests { #[test] fn test_component_with_optional_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2683,6 +2722,7 @@ mod tests { // Angular's reference compiler filters out `null` literal type nodes from // the union; when exactly one type remains it becomes the token. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2712,6 +2752,7 @@ mod tests { // token resolved to `MyService`. Token resolution is independent of // optionality — `@Optional()` only controls the inject flag. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2738,6 +2779,7 @@ mod tests { fn test_component_nullable_type_null_first() { // `null` can appear in either position of the union — both must be filtered. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2762,6 +2804,7 @@ mod tests { // unwrap it before checking for unions, or the dependency falls through // to `ɵɵinvalidFactoryDep` (issue #285 follow-up). let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2786,6 +2829,7 @@ mod tests { fn test_component_paren_around_inner_type_in_union() { // Parens around just the non-null side: `(MyService) | null`. let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2806,6 +2850,7 @@ mod tests { #[test] fn test_component_with_skip_self_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2830,6 +2875,7 @@ mod tests { #[test] fn test_component_with_self_and_host_decorators() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2858,6 +2904,7 @@ mod tests { #[test] fn test_component_with_combined_decorators() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2881,6 +2928,7 @@ mod tests { #[test] fn test_component_with_attribute_decorator() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2909,6 +2957,7 @@ mod tests { use super::super::metadata::DeclarationListEmitMode; let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2928,6 +2977,7 @@ mod tests { use super::super::metadata::DeclarationListEmitMode; let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '', @@ -2948,6 +2998,7 @@ mod tests { // When implicit_standalone=false (Angular v18 and earlier behavior) let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2968,6 +3019,7 @@ mod tests { // When implicit_standalone=true (Angular v19+ behavior) let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2987,6 +3039,7 @@ mod tests { use super::super::metadata::DeclarationListEmitMode; let code = r#" + import {Component} from '@angular/core'; import { AsyncPipe, DatePipe } from '@angular/common'; @Component({ @@ -3011,6 +3064,7 @@ mod tests { use super::super::metadata::DeclarationListEmitMode; let code = r#" + import {Component} from '@angular/core'; const MY_IMPORTS = [AsyncPipe, DatePipe]; @Component({ @@ -3038,6 +3092,7 @@ mod tests { fn test_constructor_dep_token_source_module_from_named_import() { // Test that token_source_module is populated from named imports let code = r#" + import {Component} from '@angular/core'; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; @Component({ @@ -3064,6 +3119,7 @@ mod tests { fn test_constructor_dep_token_source_module_multiple_imports() { // Test that multiple imports from the same module are tracked correctly let code = r#" + import {Component} from '@angular/core'; import { ServiceA, ServiceB } from "./services"; import { Router } from "@angular/router"; @@ -3101,6 +3157,7 @@ mod tests { fn test_constructor_dep_token_source_module_with_inject_decorator() { // Test that @Inject token source module is tracked from import let code = r#" + import {Component} from '@angular/core'; import { WINDOW } from "@bitwarden/common"; @Component({ @@ -3126,6 +3183,7 @@ mod tests { fn test_constructor_dep_no_source_module_for_local_class() { // Test that local classes (not imported) have no token_source_module let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3150,6 +3208,7 @@ mod tests { fn test_constructor_dep_token_source_module_with_alias() { // Test that aliased imports use the local name as key let code = r#" + import {Component} from '@angular/core'; import { AuthService as Auth } from "@bitwarden/common/auth"; @Component({ @@ -3178,6 +3237,7 @@ mod tests { fn test_constructor_dep_token_source_module_default_import() { // Test that default imports are tracked let code = r#" + import {Component} from '@angular/core'; import DefaultService from "@bitwarden/common/default"; @Component({ @@ -3422,6 +3482,7 @@ mod tests { #[test] fn test_lifecycle_ng_on_changes_detected() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3443,6 +3504,7 @@ mod tests { #[test] fn test_lifecycle_ng_on_changes_not_detected_without_method() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3464,6 +3526,7 @@ mod tests { #[test] fn test_lifecycle_ng_on_changes_not_detected_for_static_method() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3485,6 +3548,7 @@ mod tests { #[test] fn test_lifecycle_ng_on_changes_async_method() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' diff --git a/crates/oxc_angular_compiler/src/component/hoist.rs b/crates/oxc_angular_compiler/src/component/hoist.rs index 8100c64c0..9e4f372c5 100644 --- a/crates/oxc_angular_compiler/src/component/hoist.rs +++ b/crates/oxc_angular_compiler/src/component/hoist.rs @@ -44,6 +44,7 @@ use oxc_semantic::Semantic; use oxc_span::GetSpan; use oxc_syntax::symbol::SymbolId; +use crate::directive::StringConsts; use crate::optimizer::Edit; use super::transform::{ImportMap, is_angular_core_export, is_angular_core_namespace}; @@ -98,6 +99,7 @@ pub fn collect_hoist_edits<'a>( source: &str, semantic: &Semantic<'a>, import_map: &ImportMap<'a>, + consts: &StringConsts<'_>, ) -> Vec { // Step 1: index top-level bindings (keyed by SymbolId). // - `symbol_to_stmt`: binding SymbolId → containing statement's `start`. @@ -162,7 +164,7 @@ pub fn collect_hoist_edits<'a>( let mut classes: Vec<(&Class<'a>, u32, HashSet, HashSet)> = Vec::new(); for stmt in &program.body { let Some((class, stmt_start_pos)) = class_of(stmt) else { continue }; - if !has_hoistable_angular_decorator(class, import_map) { + if !has_hoistable_angular_decorator(class, import_map, consts) { continue; } let mut direct: HashSet = HashSet::new(); @@ -887,64 +889,36 @@ fn class_of<'a, 'src>(stmt: &'src Statement<'a>) -> Option<(&'src Class<'a>, u32 } } -/// Does this class carry any decorator that Angular's compiler emits eager -/// definitions for? We don't try to be precise here — any of the well-known -/// Angular decorators makes the class a candidate. -/// -/// Name-only and import-agnostic: used by the cheap pre-check -/// [`program_has_angular_decorated_class`], where a false positive only costs -/// a wasted scan. The actual hoist filter uses the import-aware -/// [`has_hoistable_angular_decorator`]. -fn has_angular_decorator(class: &Class<'_>) -> bool { - class.decorators.iter().any(|d| { - let callee = match &d.expression { - Expression::CallExpression(call) => &call.callee, - expr => expr, - }; - let name = match callee { - Expression::Identifier(id) => id.name.as_str(), - Expression::StaticMemberExpression(member) => member.property.name.as_str(), - _ => return false, - }; - matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable" | "Service") - }) -} - /// Whether this class carries an Angular decorator that warrants hoisting its -/// referenced declarations. -/// -/// Like [`has_angular_decorator`], but verifies `@Service` resolves to -/// `@angular/core` before treating it as Angular. `Service` is a common name -/// in non-Angular code, and hoisting a third-party `@Service` class's -/// referenced declarations would reorder statements and change that class's -/// runtime evaluation semantics. The other decorator names predate this check -/// and stay name-only — over-triggering there is the long-standing behavior and -/// only ever hoists a TDZ-safe declaration earlier. -fn has_hoistable_angular_decorator<'a>(class: &Class<'a>, import_map: &ImportMap<'a>) -> bool { +/// referenced declarations: one the compiler compiles the class for, imported +/// from `@angular/core` (see [`crate::directive::angular_class_decorator`]), +/// or an `@Service` that resolves to `@angular/core`. A same-named decorator +/// from another library (`Service` is a common name in non-Angular code) +/// leaves the class alone, and hoisting its referenced declarations would +/// reorder statements and change that class's runtime evaluation semantics. +fn has_hoistable_angular_decorator<'a>( + class: &Class<'a>, + import_map: &ImportMap<'a>, + consts: &StringConsts<'_>, +) -> bool { class.decorators.iter().any(|d| { + if crate::directive::angular_class_decorator(d, consts).is_some() { + return true; + } let callee = match &d.expression { Expression::CallExpression(call) => &call.callee, expr => expr, }; match callee { Expression::Identifier(id) => { - let name = id.name.as_str(); - if name == "Service" { - is_angular_core_export(import_map, name, "Service") - } else { - matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable") - } + id.name == "Service" && is_angular_core_export(import_map, &id.name, "Service") } + // `@ns.Service()` — accept only when `ns` is a namespace import + // from `@angular/core`. Expression::StaticMemberExpression(member) => { - let name = member.property.name.as_str(); - if name == "Service" { - // `@ns.Service()` — accept only when `ns` is a namespace - // import from `@angular/core`. - matches!(&member.object, Expression::Identifier(ns) + member.property.name == "Service" + && matches!(&member.object, Expression::Identifier(ns) if is_angular_core_namespace(import_map, ns.name.as_str())) - } else { - matches!(name, "Component" | "Directive" | "Pipe" | "NgModule" | "Injectable") - } } _ => false, } @@ -952,16 +926,21 @@ fn has_hoistable_angular_decorator<'a>(class: &Class<'a>, import_map: &ImportMap } /// Cheap pre-check: does `program` contain any top-level class statement -/// carrying one of the Angular decorators recognized by [`has_angular_decorator`]? +/// carrying one of the Angular decorators recognized by +/// [`has_hoistable_angular_decorator`]? /// /// Used by the AOT transform pipeline to skip the `Semantic` build and the /// full hoist scan for files with no decorated classes (plain TS helpers, /// type-only modules, classes with no Angular decorator, …). This walks /// `program.body` only and never descends into class bodies or expressions, /// so it's O(top-level statements) with a tiny per-statement cost. -pub(crate) fn program_has_angular_decorated_class(program: &Program<'_>) -> bool { +pub(crate) fn program_has_angular_decorated_class<'a>( + program: &Program<'a>, + import_map: &ImportMap<'a>, + consts: &StringConsts<'_>, +) -> bool { program.body.iter().any(|stmt| match class_of(stmt) { - Some((class, _)) => has_angular_decorator(class), + Some((class, _)) => has_hoistable_angular_decorator(class, import_map, consts), None => false, }) } diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index ba49fcc38..815f5f553 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -24,7 +24,7 @@ use crate::optimizer::{Edit, apply_edits, apply_edits_with_sourcemap}; use super::cross_file_elision::CrossFileAnalyzer; use super::decorator::{ collect_constructor_decorator_spans, collect_member_decorator_spans, - extract_component_metadata, find_component_decorator, find_component_decorator_span, + extract_component_metadata, find_component_decorator, }; use super::definition::{const_value_to_expression, generate_component_definitions}; use super::hoist::{collect_hoist_edits, program_has_angular_decorated_class}; @@ -42,16 +42,15 @@ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, decorator_io_errors, extract_class_queries, extract_directive_metadata, - find_directive_decorator, find_directive_decorator_span, generate_directive_definitions, + find_directive_decorator, generate_directive_definitions, }; use crate::dts; use crate::injectable::{ - extract_injectable_metadata, find_injectable_decorator, find_injectable_decorator_span, + extract_injectable_metadata_in, find_injectable_decorator, generate_injectable_definition_from_decorator, }; use crate::ng_module::{ - extract_ng_module_metadata, find_ng_module_decorator, find_ng_module_decorator_span, - generate_full_ng_module_definition, + extract_ng_module_metadata_in, find_ng_module_decorator, generate_full_ng_module_definition, }; use crate::output::ast::{ DeclareFunctionStmt, FunctionExpr, OutputExpression, OutputStatement, ReadPropExpr, @@ -62,8 +61,7 @@ use crate::parser::ParseTemplateOptions; use crate::parser::expression::BindingParser; use crate::parser::html::{HtmlParser, remove_whitespaces}; use crate::pipe::{ - extract_pipe_metadata, find_pipe_decorator, find_pipe_decorator_span, - generate_full_pipe_definition_from_decorator, + extract_pipe_metadata_in, find_pipe_decorator, generate_full_pipe_definition_from_decorator, }; use crate::pipeline::compilation::{DeferBlockDepsEmitMode, TemplateCompilationMode}; use crate::pipeline::emit::{ @@ -988,88 +986,41 @@ pub(crate) fn is_angular_core_namespace(import_map: &ImportMap<'_>, local_name: /// Return the name of the first non-`Service` `@angular/core` decorator on /// the class, if any. Used to enforce upstream's collision rule (see /// `service.ts:101-116`): `@Service` cannot coexist with another Angular -/// decorator on the same class. -fn find_conflicting_angular_decorator<'a>( - class: &'a oxc_ast::ast::Class<'a>, - import_map: &ImportMap<'a>, -) -> Option<&'a str> { - const ANGULAR_DECORATORS: &[&str] = - &["Component", "Directive", "Pipe", "Injectable", "NgModule"]; - - for decorator in &class.decorators { - let name = match &decorator.expression { - Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(id) => id.name.as_str(), - Expression::StaticMemberExpression(member) => member.property.name.as_str(), - _ => continue, - }, - _ => continue, - }; - if !ANGULAR_DECORATORS.contains(&name) { - continue; - } - // Verify the import resolves to @angular/core for both identifier - // and namespace callees. Without the namespace lookup, an unrelated - // `@thirdParty.Component()` on an @Service class would falsely - // trigger the collision preflight and block valid services. - let is_angular = if let Expression::CallExpression(call) = &decorator.expression { - match &call.callee { - Expression::Identifier(id) => import_map - .get(&Ident::from(id.name.as_str())) - .map(|info| info.source_module.as_str() == "@angular/core") - .unwrap_or(false), - Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(ns) => { - is_angular_core_namespace(import_map, ns.name.as_str()) - } - _ => false, - }, - _ => false, - } - } else { - false - }; - if is_angular { - return Some(name); - } - } - None +/// decorator on the same class. Only Angular's count (imported from +/// `@angular/core`, see [`crate::directive::angular_class_decorator`]): an +/// unrelated `@thirdParty.Component()` on an @Service class doesn't block it. +fn find_conflicting_angular_decorator( + class: &oxc_ast::ast::Class<'_>, + string_consts: &crate::directive::StringConsts<'_>, +) -> Option<&'static str> { + class + .decorators + .iter() + .filter(|d| matches!(d.expression, Expression::CallExpression(_))) + .find_map(|d| crate::directive::angular_class_decorator(d, string_consts)) } /// Find any Angular decorator on a class and return its kind and the decorator reference. /// -/// For the `Service` identifier specifically, the import map is consulted so a -/// bare `@Service()` from a non-Angular library doesn't shadow a real Angular -/// decorator that follows it on the same class. `Service` is common enough as a -/// library export name (DI containers, web frameworks) that name-only matching -/// would cause the JIT pipeline to either misclassify the class or, on -/// pre-v22 targets, emit a misleading diagnostic and skip the sibling -/// `@Component`/`@Injectable`/etc. Other Angular decorator names are unique -/// enough in practice that the same check isn't applied to them — and doing so -/// would regress namespace-style usage like `@core.Component()` where the -/// identifier isn't directly in the import map. +/// Like ngtsc, a decorator is Angular's only when it's imported from +/// `@angular/core`, by name under any alias or through a namespace import (see +/// [`crate::directive::angular_class_decorator`]); a same-named decorator from +/// another library, a local or an undeclared one is left alone. `@Service` is +/// matched against the import map the same way. fn find_angular_decorator<'a>( class: &'a oxc_ast::ast::Class<'a>, import_map: &ImportMap<'a>, + string_consts: &crate::directive::StringConsts<'_>, ) -> Option<(AngularDecoratorKind, &'a oxc_ast::ast::Decorator<'a>)> { for decorator in &class.decorators { - if let Expression::CallExpression(call) = &decorator.expression { - let name = match &call.callee { - Expression::Identifier(id) => Some(id.name.as_str()), - Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()), - _ => None, - }; - let kind = match name { - Some("Component") => Some(AngularDecoratorKind::Component), - Some("Directive") => Some(AngularDecoratorKind::Directive), - Some("Pipe") => Some(AngularDecoratorKind::Pipe), - Some("Injectable") => Some(AngularDecoratorKind::Injectable), - Some("Service") => Some(AngularDecoratorKind::Service), - Some("NgModule") => Some(AngularDecoratorKind::NgModule), - _ => None, - }; - - if matches!(kind, Some(AngularDecoratorKind::Service)) { + let Expression::CallExpression(call) = &decorator.expression else { continue }; + let kind = match crate::directive::angular_class_decorator(decorator, string_consts) { + Some("Component") => AngularDecoratorKind::Component, + Some("Directive") => AngularDecoratorKind::Directive, + Some("Pipe") => AngularDecoratorKind::Pipe, + Some("Injectable") => AngularDecoratorKind::Injectable, + Some("NgModule") => AngularDecoratorKind::NgModule, + _ => { let from_angular_core = match &call.callee { Expression::Identifier(id) => { is_angular_core_export(import_map, id.name.as_str(), "Service") @@ -1078,23 +1029,20 @@ fn find_angular_decorator<'a>( // namespace import from `@angular/core`. Without this, // any `@third.Service()` from a third-party namespace // import would classify as the v22 decorator. - Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(ns) => { - is_angular_core_namespace(import_map, ns.name.as_str()) - } - _ => false, - }, + Expression::StaticMemberExpression(member) => { + member.property.name == "Service" + && matches!(&member.object, Expression::Identifier(ns) + if is_angular_core_namespace(import_map, ns.name.as_str())) + } _ => false, }; if !from_angular_core { continue; } + AngularDecoratorKind::Service } - - if let Some(k) = kind { - return Some((k, decorator)); - } - } + }; + return Some((kind, decorator)); } None } @@ -2152,7 +2100,8 @@ fn transform_angular_file_jit( continue; }; - let Some((decorator_kind, angular_decorator)) = find_angular_decorator(class, &import_map) + let Some((decorator_kind, angular_decorator)) = + find_angular_decorator(class, &import_map, &string_consts) else { continue; }; @@ -2619,7 +2568,8 @@ pub fn transform_angular_file( // (leaving the @Service decorator removed inconsistently) instead // of producing the intended diagnostic. if find_angular_service_decorator(class, &import_map).is_some() { - if let Some(conflict_name) = find_conflicting_angular_decorator(class, &import_map) + if let Some(conflict_name) = + find_conflicting_angular_decorator(class, &string_consts) { let class_name_for_diag = class.id.as_ref().map_or(String::new(), |id| id.name.to_string()); @@ -2736,7 +2686,10 @@ pub fn transform_angular_file( } // Track the decorator span to remove - if let Some(span) = find_component_decorator_span(class) { + if let Some(span) = + find_component_decorator(&class.decorators, &string_consts) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) @@ -2763,10 +2716,19 @@ pub fn transform_angular_file( // Check if the class also has an @Injectable decorator. // @Injectable is SHARED precedence and can coexist with @Component. - let has_injectable = - extract_injectable_metadata(allocator, class, Some(source)); + let has_injectable = extract_injectable_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ); if let Some(injectable_metadata) = &has_injectable { - if let Some(span) = find_injectable_decorator_span(class) { + if let Some(span) = find_injectable_decorator( + &class.decorators, + Some(&string_consts), + ) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } if let Some(inj_def) = generate_injectable_definition_from_decorator( @@ -2801,7 +2763,8 @@ pub fn transform_angular_file( // Add class metadata for TestBed support (after debug info, before HMR) // Only emit when enabled and not in advanced optimizations mode if options.emit_class_metadata && !options.advanced_optimizations { - if let Some(decorator) = find_component_decorator(&class.decorators) + if let Some(decorator) = + find_component_decorator(&class.decorators, &string_consts) { let emitter = JsEmitter::new(); @@ -2991,7 +2954,10 @@ pub fn transform_angular_file( &string_consts, ) { // Track decorator span for removal - if let Some(span) = find_directive_decorator_span(class) { + if let Some(span) = + find_directive_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) @@ -3052,10 +3018,17 @@ pub fn transform_angular_file( // Check if the class also has an @Injectable decorator. // @Injectable is SHARED precedence and can coexist with @Directive. - let has_injectable = - extract_injectable_metadata(allocator, class, Some(source)); + let has_injectable = extract_injectable_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ); if let Some(injectable_metadata) = &has_injectable { - if let Some(span) = find_injectable_decorator_span(class) { + if let Some(span) = + find_injectable_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } if let Some(inj_def) = generate_injectable_definition_from_decorator( @@ -3085,21 +3058,22 @@ pub fn transform_angular_file( // Emit setClassMetadata for TestBed support (overrideDirective + // signal members), mirroring the @Component path. - let decls_after_class = find_directive_decorator(&class.decorators) - .map(|decorator| { - build_set_class_metadata_decls( - &allocator, - class, - &class_name, - decorator, - options, - source, - &string_consts, - &import_map, - &mut file_namespace_registry, - ) - }) - .unwrap_or_default(); + let decls_after_class = + find_directive_decorator(&class.decorators, Some(&string_consts)) + .map(|decorator| { + build_set_class_metadata_decls( + &allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); class_positions.push(( class_name.clone(), @@ -3110,16 +3084,22 @@ pub fn transform_angular_file( class_name, (property_assignments, String::new(), decls_after_class), ); - } else if let Some(mut pipe_metadata) = - extract_pipe_metadata(allocator, class, implicit_standalone, Some(source)) - { + } else if let Some(mut pipe_metadata) = extract_pipe_metadata_in( + allocator, + class, + implicit_standalone, + Some(source), + Some(&string_consts), + ) { // Not a @Component or @Directive - check if it's a @Pipe (PRIMARY) // We need to compile @Pipe classes to generate ɵpipe and ɵfac definitions. // - ɵpipe: Pipe definition for Angular's pipe system // - ɵfac: Factory function for dependency injection (when pipe has constructor deps) // Track decorator span for removal - if let Some(span) = find_pipe_decorator_span(class) { + if let Some(span) = + find_pipe_decorator(&class.decorators, Some(&string_consts)).map(|d| d.span) + { decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) @@ -3154,10 +3134,17 @@ pub fn transform_angular_file( // Check if the class also has an @Injectable decorator (issue #65). // @Injectable is SHARED precedence and can coexist with @Pipe. - let has_injectable = - extract_injectable_metadata(allocator, class, Some(source)); + let has_injectable = extract_injectable_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ); if let Some(injectable_metadata) = &has_injectable { - if let Some(span) = find_injectable_decorator_span(class) { + if let Some(span) = + find_injectable_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } if let Some(inj_def) = generate_injectable_definition_from_decorator( @@ -3183,21 +3170,22 @@ pub fn transform_angular_file( )); // Emit setClassMetadata for TestBed support (overridePipe). - let decls_after_class = find_pipe_decorator(&class.decorators) - .map(|decorator| { - build_set_class_metadata_decls( - &allocator, - class, - &class_name, - decorator, - options, - source, - &string_consts, - &import_map, - &mut file_namespace_registry, - ) - }) - .unwrap_or_default(); + let decls_after_class = + find_pipe_decorator(&class.decorators, Some(&string_consts)) + .map(|decorator| { + build_set_class_metadata_decls( + &allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); class_positions.push(( class_name.clone(), @@ -3209,9 +3197,12 @@ pub fn transform_angular_file( (property_assignments, String::new(), decls_after_class), ); } - } else if let Some(mut ng_module_metadata) = - extract_ng_module_metadata(allocator, class, Some(source)) - { + } else if let Some(mut ng_module_metadata) = extract_ng_module_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ) { // Not a @Component, @Directive, @Injectable, or @Pipe - check if it's an @NgModule // We need to compile @NgModule classes to generate ɵmod, ɵfac, and ɵinj definitions. // - ɵmod: NgModule definition @@ -3219,7 +3210,10 @@ pub fn transform_angular_file( // - ɵinj: Injector definition with providers and imports // Track decorator span for removal - if let Some(span) = find_ng_module_decorator_span(class) { + if let Some(span) = + find_ng_module_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) @@ -3256,10 +3250,17 @@ pub fn transform_angular_file( // Check if the class also has an @Injectable decorator. // @Injectable is SHARED precedence and can coexist with @NgModule. - let has_injectable = - extract_injectable_metadata(allocator, class, Some(source)); + let has_injectable = extract_injectable_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ); if let Some(injectable_metadata) = &has_injectable { - if let Some(span) = find_injectable_decorator_span(class) { + if let Some(span) = + find_injectable_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } if let Some(inj_def) = generate_injectable_definition_from_decorator( @@ -3295,7 +3296,9 @@ pub fn transform_angular_file( // Emit setClassMetadata for TestBed support (overrideModule), // appended after the NgModule's external declarations. - if let Some(decorator) = find_ng_module_decorator(&class.decorators) { + if let Some(decorator) = + find_ng_module_decorator(&class.decorators, Some(&string_consts)) + { let metadata = build_set_class_metadata_decls( &allocator, class, @@ -3416,16 +3419,22 @@ pub fn transform_angular_file( (property_assignments, String::new(), decls_after_class), ); } - } else if let Some(mut injectable_metadata) = - extract_injectable_metadata(allocator, class, Some(source)) - { + } else if let Some(mut injectable_metadata) = extract_injectable_metadata_in( + allocator, + class, + Some(source), + Some(&string_consts), + ) { // Standalone @Injectable (no PRIMARY decorator on the class) // We need to compile @Injectable classes to generate ɵprov and ɵfac definitions. // - ɵprov: Provider metadata for Angular's DI system // - ɵfac: Factory function to instantiate the class // Track decorator span for removal - if let Some(span) = find_injectable_decorator_span(class) { + if let Some(span) = + find_injectable_decorator(&class.decorators, Some(&string_consts)) + .map(|d| d.span) + { decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) @@ -3466,21 +3475,22 @@ pub fn transform_angular_file( )); // Emit setClassMetadata for TestBed support. - let decls_after_class = find_injectable_decorator(&class.decorators) - .map(|decorator| { - build_set_class_metadata_decls( - &allocator, - class, - &class_name, - decorator, - options, - source, - &string_consts, - &import_map, - &mut file_namespace_registry, - ) - }) - .unwrap_or_default(); + let decls_after_class = + find_injectable_decorator(&class.decorators, Some(&string_consts)) + .map(|decorator| { + build_set_class_metadata_decls( + &allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ) + }) + .unwrap_or_default(); class_positions.push(( class_name.clone(), @@ -3605,7 +3615,7 @@ pub fn transform_angular_file( // services without `@Injectable` that we route through this function. For // those, building a full symbol table just to discover there's nothing to // hoist is pure overhead. - if program_has_angular_decorated_class(&parser_ret.program) { + if program_has_angular_decorated_class(&parser_ret.program, &import_map, &string_consts) { // Semantic builder errors (redeclarations, etc.) are intentionally // dropped: the parser already captured syntax errors into // `result.diagnostics` upstream, and Semantic-level diagnostics here @@ -3619,6 +3629,7 @@ pub fn transform_angular_file( source, &hoist_semantic, &import_map, + &string_consts, )); } diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 21ccf7c01..fab81818d 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -24,10 +24,17 @@ use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputAstBuilder, OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; -/// Find the @Directive decorator in a list of decorators. +/// Find the @Directive decorator in a list of decorators. With the file's +/// `consts`, only Angular's (imported from `@angular/core`, see +/// [`super::find_angular_class_decorator`]); without them (the public +/// [`find_directive_decorator_span`]), any named `Directive`. pub(crate) fn find_directive_decorator<'a>( decorators: &'a [Decorator<'a>], + consts: Option<&StringConsts<'_>>, ) -> Option<&'a Decorator<'a>> { + if let Some(consts) = consts { + return super::find_angular_class_decorator(decorators, "Directive", consts); + } decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_directive_call(&call.callee), Expression::Identifier(id) => id.name == "Directive", @@ -43,8 +50,11 @@ pub(crate) fn find_directive_decorator<'a>( /// This is necessary because Angular's JIT runtime will process any remaining /// decorators and create conflicting property definitions (like `ɵfac` getters) /// that interfere with the AOT-compiled assignments. +/// +/// Without the file's imports, this matches any decorator named `Directive`; +/// the compiler only takes one imported from `@angular/core`. pub fn find_directive_decorator_span(class: &Class<'_>) -> Option { - find_directive_decorator(&class.decorators).map(|d| d.span) + find_directive_decorator(&class.decorators, None).map(|d| d.span) } /// Check if a callee expression is a call to 'Directive'. @@ -92,8 +102,8 @@ pub fn extract_directive_metadata<'a>( // Get the class name let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); - // Find the @Directive decorator - let directive_decorator = find_directive_decorator(&class.decorators)?; + // Find Angular's @Directive decorator + let directive_decorator = find_directive_decorator(&class.decorators, Some(consts))?; // Get the decorator call arguments let call_expr = match &directive_decorator.expression { @@ -101,11 +111,6 @@ pub fn extract_directive_metadata<'a>( _ => return None, }; - // Verify it's calling 'Directive' - if !is_directive_call(&call_expr.callee) { - return None; - } - // Create builder with defaults let mut builder = R3DirectiveMetadataBuilder::new(allocator) .name(class_name.clone()) @@ -1016,14 +1021,18 @@ fn upsert_input<'a>(inputs: &mut Vec<'a, R3InputMetadata<'a>>, input: R3InputMet upsert_meta(inputs, input, |i| i.class_property_name.as_str()); } -/// The `@Component` / `@Directive` decorator on `class`, its metadata object -/// (if any) and its name. +/// Angular's `@Component` / `@Directive` decorator on `class` (imported from +/// `@angular/core`, in the file `consts` was collected from), its metadata +/// object (if any) and its name. pub(crate) fn angular_decorator_config<'a>( class: &'a Class<'a>, + consts: &StringConsts<'_>, ) -> Option<(Option<&'a ObjectExpression<'a>>, &'static str)> { - let (decorator, name) = crate::component::find_component_decorator(&class.decorators) + let (decorator, name) = crate::component::find_component_decorator(&class.decorators, consts) .map(|d| (d, "Component")) - .or_else(|| find_directive_decorator(&class.decorators).map(|d| (d, "Directive")))?; + .or_else(|| { + find_directive_decorator(&class.decorators, Some(consts)).map(|d| (d, "Directive")) + })?; let config = match &decorator.expression { Expression::CallExpression(call) => match call.arguments.first() { Some(Argument::ObjectExpression(config)) => Some(&**config), @@ -1049,7 +1058,7 @@ pub fn decorator_io_errors<'a>( source_text: Option<&'a str>, consts: &StringConsts<'a>, ) -> std::vec::Vec { - let Some((config, decorator_name)) = angular_decorator_config(class) else { + let Some((config, decorator_name)) = angular_decorator_config(class, consts) else { return std::vec::Vec::new(); }; let io = config.map(|config| parse_decorator_io(allocator, config, class, source_text, consts)); @@ -1819,6 +1828,7 @@ mod tests { #[test] fn test_extract_directive_selector() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appHighlight]' }) class HighlightDirective {} "#; @@ -1831,6 +1841,7 @@ mod tests { #[test] fn test_extract_directive_standalone_true() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', standalone: true @@ -1845,6 +1856,7 @@ mod tests { #[test] fn test_extract_directive_standalone_false() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', standalone: false @@ -1859,6 +1871,7 @@ mod tests { #[test] fn test_extract_directive_standalone_defaults_to_implicit() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective {} "#; @@ -1875,6 +1888,7 @@ mod tests { #[test] fn test_extract_directive_export_as() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', exportAs: 'testDir' @@ -1890,6 +1904,7 @@ mod tests { #[test] fn test_extract_directive_export_as_multiple() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', exportAs: 'foo, bar, baz' @@ -1907,6 +1922,7 @@ mod tests { #[test] fn test_extract_directive_host_property_bindings() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -1924,6 +1940,7 @@ mod tests { #[test] fn test_extract_directive_host_listeners() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -1941,6 +1958,7 @@ mod tests { #[test] fn test_extract_directive_host_static_attributes() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -1961,6 +1979,7 @@ mod tests { #[test] fn test_extract_directive_host_computed_key_identifier() { let code = r#" + import {Directive} from '@angular/core'; const ATTR = 'data-foo'; @Directive({ selector: '[d]', host: { [ATTR]: '' } }) class D {} @@ -1974,6 +1993,7 @@ mod tests { #[test] fn test_extract_directive_host_value_identifier() { let code = r#" + import {Directive} from '@angular/core'; const VAL = 'submit'; @Directive({ selector: '[d]', host: { type: VAL } }) class D {} @@ -1987,6 +2007,7 @@ mod tests { #[test] fn test_extract_directive_host_template_literal_const() { let code = r#" + import {Directive} from '@angular/core'; const ATTR = `data-foo`; @Directive({ selector: '[d]', host: { [ATTR]: '' } }) class D {} @@ -2001,6 +2022,7 @@ mod tests { fn test_extract_directive_host_unknown_identifier_dropped() { // Unresolved identifier (no matching const) is still dropped — current behavior. let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[d]', host: { [UNKNOWN]: '' } }) class D {} "#; @@ -2013,6 +2035,7 @@ mod tests { fn test_extract_directive_host_exported_const_identifier() { // `export const` (not just `const`) in the same file must also be resolved. let code = r#" + import {Directive} from '@angular/core'; export const MARKER_ATTR = 'data-marker'; @Directive({ selector: '[marker]', @@ -2029,6 +2052,7 @@ mod tests { #[test] fn test_extract_directive_host_class_attr() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -2045,6 +2069,7 @@ mod tests { #[test] fn test_extract_directive_host_style_attr() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', host: { @@ -2064,6 +2089,7 @@ mod tests { #[test] fn test_extract_directive_host_directives_simple() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', hostDirectives: [TooltipDirective] @@ -2079,6 +2105,7 @@ mod tests { #[test] fn test_extract_directive_host_directives_with_mappings() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', hostDirectives: [ @@ -2104,6 +2131,7 @@ mod tests { #[test] fn test_extract_directive_host_directives_forward_ref() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]', hostDirectives: [forwardRef(() => MyDirective)] @@ -2221,6 +2249,7 @@ mod tests { #[test] fn test_extract_directive_component_decorator_does_not_match() { let code = r#" + import {Component} from '@angular/core'; @Component({ selector: 'app-test', template: '' }) class TestComponent {} "#; @@ -2242,6 +2271,7 @@ mod tests { fn test_empty_directive_decorator() { // @Directive({}) - explicit empty config object let code = r#" + import {Directive} from '@angular/core'; @Directive({}) class TestDirective {} "#; @@ -2256,6 +2286,7 @@ mod tests { // @Directive() - no config argument at all // This is common for abstract base directive classes let code = r#" + import {Directive} from '@angular/core'; @Directive() class TestDirective {} "#; @@ -2268,6 +2299,7 @@ mod tests { #[test] fn test_exported_directive() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]' }) export class TestDirective {} "#; @@ -2279,6 +2311,7 @@ mod tests { #[test] fn test_export_default_directive() { let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]' }) export default class TestDirective {} "#; @@ -2290,6 +2323,7 @@ mod tests { #[test] fn test_namespaced_directive_decorator() { let code = r#" + import * as ng from '@angular/core'; @ng.Directive({ selector: '[appTest]' }) class TestDirective {} "#; @@ -2341,6 +2375,7 @@ mod tests { // A directive that does NOT extend any base class // should have uses_inheritance = false let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appTest]' }) class TestDirective {} "#; @@ -2354,6 +2389,7 @@ mod tests { // A directive that extends a base class // should have uses_inheritance = true let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[appChild]' }) class ChildDirective extends BaseDirective {} "#; @@ -2367,6 +2403,7 @@ mod tests { // Regression test for issue #285: // `@Optional() svc: MyService | null` must resolve the token to `MyService`. let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[myDir]' }) class MyDirective { constructor(@Optional() private svc: MyService | null) {} @@ -2397,6 +2434,7 @@ mod tests { // The fix: Changed extract_param_token to return ReadVar(TypeName) // matching the pattern used by injectable, pipe, and ng_module extractors. let code = r#" + import {Directive} from '@angular/core'; @Directive({ selector: '[myDir]' }) class MyDirective { constructor(private store: Store, private svc: SomeService) {} diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 5132c00a5..983f27530 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3393,7 +3393,7 @@ pub fn input_transform_types<'a>( None => transforms.push((name, transform)), } }; - if let Some((Some(config), _)) = super::angular_decorator_config(class) + if let Some((Some(config), _)) = super::angular_decorator_config(class, consts) && let Some(inputs) = super::decorator::config_property(config, "inputs", consts) && let Value::Array(items) = evaluator.evaluate(inputs) { diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 14bbf0a55..f77a06626 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -47,13 +47,13 @@ pub use metadata::{ R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; pub(crate) use property_decorators::{ - angular_member_decorator, extract_host_bindings_in, extract_host_listeners_in, - parse_decorator_queries, try_parse_signal_input, try_parse_signal_model, - try_parse_signal_output, unwrap_initializer_api_expr, + angular_class_decorator, angular_member_decorator, extract_host_bindings_in, + extract_host_listeners_in, parse_decorator_queries, try_parse_signal_input, + try_parse_signal_model, try_parse_signal_output, unwrap_initializer_api_expr, }; pub use property_decorators::{ extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_input_metadata_in, extract_output_metadata, - extract_output_metadata_in, extract_view_queries, + extract_output_metadata_in, extract_view_queries, find_angular_class_decorator, }; pub use query::{create_content_queries_function, create_view_queries_function}; diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 5191a1284..2ab293f65 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -52,16 +52,21 @@ const MEMBER_DECORATORS: &[&str] = &[ "ContentChildren", ]; -/// Which of Angular's member decorators ([`MEMBER_DECORATORS`]) `decorator` -/// is: `@Input`, `@Input()`, ... With the file `consts` was collected from, -/// only one imported from `@angular/core`, like ngtsc: by name under any alias +/// Angular's class decorators, which make a class compiled. (`@Service` is +/// matched on its own, see `find_angular_service_decorator`.) +const CLASS_DECORATORS: &[&str] = &["Component", "Directive", "Pipe", "Injectable", "NgModule"]; + +/// Which of Angular's decorators `names` `decorator` is: `@X`, `@X()`, ... +/// With the file `consts` was collected from, only one imported from +/// `@angular/core`, like ngtsc's `isAngularDecorator`: by name under any alias /// (`@In()` for `import { Input as In }`) or through a namespace import -/// (`@core.Input()`, see [`is_core_namespace`]); another module's or a local -/// `Input` isn't Angular's. Without the file (the public `extract_*` -/// functions), any decorator with that name. -pub(crate) fn angular_member_decorator( +/// (`@core.Input()`, see [`is_core_namespace`]); another module's (even one +/// re-exporting Angular's), a local or an undeclared `X` isn't Angular's. +/// Without the file, any decorator with that name. +fn angular_core_decorator( decorator: &Decorator<'_>, consts: Option<&super::StringConsts<'_>>, + names: &[&'static str], ) -> Option<&'static str> { let callee = match &decorator.expression { Expression::CallExpression(call) => &call.callee, @@ -86,7 +91,41 @@ pub(crate) fn angular_member_decorator( }, _ => return None, }; - MEMBER_DECORATORS.iter().copied().find(|n| *n == name) + names.iter().copied().find(|n| *n == name) +} + +/// Which of Angular's member decorators ([`MEMBER_DECORATORS`]) `decorator` +/// is (see [`angular_core_decorator`]). Without the file (the public +/// `extract_*` functions), any decorator with that name. +pub(crate) fn angular_member_decorator( + decorator: &Decorator<'_>, + consts: Option<&super::StringConsts<'_>>, +) -> Option<&'static str> { + angular_core_decorator(decorator, consts, MEMBER_DECORATORS) +} + +/// Which of Angular's class decorators ([`CLASS_DECORATORS`]) `decorator` is, +/// in the file `consts` was collected from: one imported from `@angular/core` +/// (see [`angular_core_decorator`]), like ngtsc's `findAngularDecorator`. +pub(crate) fn angular_class_decorator( + decorator: &Decorator<'_>, + consts: &super::StringConsts<'_>, +) -> Option<&'static str> { + angular_core_decorator(decorator, Some(consts), CLASS_DECORATORS) +} + +/// Angular's class decorator `name` (`Component`, `Directive`, `Pipe`, +/// `Injectable` or `NgModule`) among `decorators`, in the file `consts` was +/// collected from. Like ngtsc's `findAngularDecorator`, only one imported from +/// `@angular/core` counts: by name under any alias (`@Cmp()` for +/// `import { Component as Cmp }`) or through a namespace import +/// (`@ng.Component()`). Returns the first match. +pub fn find_angular_class_decorator<'a>( + decorators: &'a [Decorator<'a>], + name: &str, + consts: &super::StringConsts<'_>, +) -> Option<&'a Decorator<'a>> { + decorators.iter().find(|d| angular_class_decorator(d, consts) == Some(name)) } /// Find one of Angular's member decorators (`name`) in a list of decorators @@ -1626,7 +1665,7 @@ pub fn extract_class_queries<'a>( ) -> (Vec<'a, R3QueryMetadata<'a>>, Vec<'a, R3QueryMetadata<'a>>) { let mut view = extract_view_queries_in(allocator, class, source_text, Some(consts)); let mut content = extract_content_queries_in(allocator, class, source_text, Some(consts)); - if let Some((Some(config), name)) = super::angular_decorator_config(class) { + if let Some((Some(config), name)) = super::angular_decorator_config(class, consts) { let queries = parse_decorator_queries(allocator, config, class, source_text, consts, name); view.extend(queries.view); content.extend(queries.content); diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 0ab8f4528..8247114dc 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -11,6 +11,7 @@ use oxc_ast::ast::{ use oxc_span::Span; use oxc_str::Ident; +use crate::directive::StringConsts; use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; @@ -206,14 +207,23 @@ fn convert_deps_to_r3<'a>( result } -/// Find the `@Injectable` decorator node on a class. +/// Find the `@Injectable` decorator node on a class. With the file's +/// `consts`, only Angular's (imported from `@angular/core`, see +/// [`crate::directive::find_angular_class_decorator`]); without them, any +/// `@Injectable` identifier. pub(crate) fn find_injectable_decorator<'a>( decorators: &'a [oxc_ast::ast::Decorator<'a>], + consts: Option<&StringConsts<'_>>, ) -> Option<&'a oxc_ast::ast::Decorator<'a>> { + if let Some(consts) = consts { + return crate::directive::find_angular_class_decorator(decorators, "Injectable", consts); + } decorators.iter().find(|d| is_injectable_decorator(d)) } -/// Find the span of the `@Injectable` decorator on a class. +/// Find the span of the `@Injectable` decorator on a class. Without the file's +/// imports, this matches any `@Injectable` identifier; the compiler only takes +/// one imported from `@angular/core`. pub fn find_injectable_decorator_span(class: &Class<'_>) -> Option { for decorator in &class.decorators { if is_injectable_decorator(decorator) { @@ -224,16 +234,31 @@ pub fn find_injectable_decorator_span(class: &Class<'_>) -> Option { } /// Extract injectable metadata from a class decorated with `@Injectable`. +/// +/// Without the file's imports, any `@Injectable` identifier counts; +/// [`extract_injectable_metadata_in`] only counts Angular's, like the compiler. pub fn extract_injectable_metadata<'a>( allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, +) -> Option> { + extract_injectable_metadata_in(allocator, class, source_text, None) +} + +/// [`extract_injectable_metadata`] for a class in the file `consts` was +/// collected from: only Angular's `@Injectable` (imported from +/// `@angular/core`) counts. +pub(crate) fn extract_injectable_metadata_in<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: Option<&StringConsts<'_>>, ) -> Option> { let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; // Find the @Injectable decorator - let decorator = class.decorators.iter().find(|d| is_injectable_decorator(d))?; + let decorator = find_injectable_decorator(&class.decorators, consts)?; // Get the decorator call expression let call_expr = match &decorator.expression { diff --git a/crates/oxc_angular_compiler/src/injectable/mod.rs b/crates/oxc_angular_compiler/src/injectable/mod.rs index 7223ed2a7..cc37195db 100644 --- a/crates/oxc_angular_compiler/src/injectable/mod.rs +++ b/crates/oxc_angular_compiler/src/injectable/mod.rs @@ -17,11 +17,11 @@ mod definition; mod metadata; pub use compiler::{InjectableCompileResult, compile_injectable, compile_injectable_from_metadata}; -pub(crate) use decorator::find_injectable_decorator; pub use decorator::{ DependencyMetadata, InjectableMetadata, ProvidedInValue, UseClassMetadata, UseExistingMetadata, UseFactoryMetadata, extract_injectable_metadata, find_injectable_decorator_span, }; +pub(crate) use decorator::{extract_injectable_metadata_in, find_injectable_decorator}; pub use definition::{ InjectableDefinition, generate_injectable_definition, generate_injectable_definition_from_decorator, diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index 991f9f01e..b95354cd4 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -87,7 +87,8 @@ pub use styles::{encapsulate_style, shim_css_text}; // Re-export pipe types pub use pipe::{ PipeCompileResult, PipeMetadata, R3DependencyMetadata, R3PipeMetadata, R3PipeMetadataBuilder, - compile_pipe, compile_pipe_from_metadata, extract_pipe_metadata, find_pipe_decorator_span, + compile_pipe, compile_pipe_from_metadata, extract_pipe_metadata, extract_pipe_metadata_in, + find_pipe_decorator_span, }; // Re-export factory types @@ -114,8 +115,8 @@ pub use directive::{ compile_directive_from_metadata, extract_class_queries, extract_content_queries, extract_directive_metadata, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_input_metadata_in, extract_output_metadata, - extract_output_metadata_in, extract_view_queries, find_directive_decorator_span, - generate_directive_definitions, + extract_output_metadata_in, extract_view_queries, find_angular_class_decorator, + find_directive_decorator_span, generate_directive_definitions, }; // Re-export injectable types diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index 0586413ff..323527ba8 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -11,6 +11,7 @@ use oxc_ast::ast::{ use oxc_span::Span; use oxc_str::Ident; +use crate::directive::StringConsts; use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; @@ -178,17 +179,32 @@ impl<'a> NgModuleMetadata<'a> { /// }) /// export class AppModule {} /// ``` +/// +/// Without the file's imports, any decorator named `NgModule` counts; +/// [`extract_ng_module_metadata_in`] only counts Angular's, like the compiler. pub fn extract_ng_module_metadata<'a>( allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, +) -> Option> { + extract_ng_module_metadata_in(allocator, class, source_text, None) +} + +/// [`extract_ng_module_metadata`] for a class in the file `consts` was +/// collected from: only Angular's `@NgModule` (imported from `@angular/core`, +/// see [`crate::directive::find_angular_class_decorator`]) counts. +pub(crate) fn extract_ng_module_metadata_in<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: Option<&StringConsts<'_>>, ) -> Option> { // Get the class name let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; // Find the @NgModule decorator - let ng_module_decorator = find_ng_module_decorator(&class.decorators)?; + let ng_module_decorator = find_ng_module_decorator(&class.decorators, consts)?; // Get the decorator call arguments let call_expr = match &ng_module_decorator.expression { @@ -196,11 +212,6 @@ pub fn extract_ng_module_metadata<'a>( _ => return None, }; - // Verify it's calling 'NgModule' - if !is_ng_module_call(&call_expr.callee) { - return None; - } - // Get the first argument (the config object) let config_arg = call_expr.arguments.first()?; let config_obj = match config_arg { @@ -279,10 +290,16 @@ pub fn extract_ng_module_metadata<'a>( Some(metadata) } -/// Find the @NgModule decorator in a list of decorators. +/// Find the @NgModule decorator in a list of decorators. With the file's +/// `consts`, only Angular's (imported from `@angular/core`); without them, any +/// named `NgModule`. pub(crate) fn find_ng_module_decorator<'a>( decorators: &'a [Decorator<'a>], + consts: Option<&StringConsts<'_>>, ) -> Option<&'a Decorator<'a>> { + if let Some(consts) = consts { + return crate::directive::find_angular_class_decorator(decorators, "NgModule", consts); + } decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_ng_module_call(&call.callee), Expression::Identifier(id) => id.name == "NgModule", @@ -293,9 +310,11 @@ pub(crate) fn find_ng_module_decorator<'a>( /// Find the span of the @NgModule decorator on a class. /// /// Returns the span including any leading whitespace/newlines that should be removed -/// along with the decorator. +/// along with the decorator. Without the file's imports, this matches any +/// decorator named `NgModule`; the compiler only takes one imported from +/// `@angular/core`. pub fn find_ng_module_decorator_span(class: &Class<'_>) -> Option { - find_ng_module_decorator(&class.decorators).map(|d| d.span) + find_ng_module_decorator(&class.decorators, None).map(|d| d.span) } /// Check if a callee expression is a call to 'NgModule'. diff --git a/crates/oxc_angular_compiler/src/ng_module/mod.rs b/crates/oxc_angular_compiler/src/ng_module/mod.rs index 8cea9511d..940f378af 100644 --- a/crates/oxc_angular_compiler/src/ng_module/mod.rs +++ b/crates/oxc_angular_compiler/src/ng_module/mod.rs @@ -17,8 +17,8 @@ mod definition; mod metadata; pub use compiler::{NgModuleCompileResult, compile_ng_module, compile_ng_module_from_metadata}; -pub(crate) use decorator::find_ng_module_decorator; pub use decorator::{NgModuleMetadata, extract_ng_module_metadata, find_ng_module_decorator_span}; +pub(crate) use decorator::{extract_ng_module_metadata_in, find_ng_module_decorator}; pub use definition::{ FullNgModuleDefinition, NgModuleDefinition, emit_full_ng_module_definition, emit_ng_module_definition, generate_full_ng_module_definition, generate_ng_module_definition, diff --git a/crates/oxc_angular_compiler/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 9bbebba97..804e62654 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -12,6 +12,7 @@ use oxc_span::Span; use oxc_str::Ident; use super::metadata::R3PipeMetadata; +use crate::directive::StringConsts; use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; @@ -105,18 +106,35 @@ impl<'a> PipeMetadata<'a> { /// }) /// export class MyPipe implements PipeTransform {} /// ``` +/// +/// Without the file's imports, any decorator named `Pipe` counts: +/// [`extract_pipe_metadata_in`] takes the file's [`StringConsts`] and only +/// counts Angular's (imported from `@angular/core`), like the compiler. pub fn extract_pipe_metadata<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + implicit_standalone: bool, + source_text: Option<&'a str>, +) -> Option> { + extract_pipe_metadata_in(allocator, class, implicit_standalone, source_text, None) +} + +/// [`extract_pipe_metadata`] for a class in the file `consts` was collected +/// from: only Angular's `@Pipe` (imported from `@angular/core`, see +/// [`crate::directive::find_angular_class_decorator`]) counts. +pub fn extract_pipe_metadata_in<'a>( allocator: &'a Allocator, class: &'a Class<'a>, implicit_standalone: bool, _source_text: Option<&'a str>, + consts: Option<&StringConsts<'_>>, ) -> Option> { // Get the class name let class_name: Ident<'a> = class.id.as_ref()?.name.clone().into(); let class_span = class.span; // Find the @Pipe decorator - let pipe_decorator = find_pipe_decorator(&class.decorators)?; + let pipe_decorator = find_pipe_decorator(&class.decorators, consts)?; // Get the decorator call arguments let call_expr = match &pipe_decorator.expression { @@ -124,11 +142,6 @@ pub fn extract_pipe_metadata<'a>( _ => return None, }; - // Verify it's calling 'Pipe' - if !is_pipe_call(&call_expr.callee) { - return None; - } - // Get the first argument (the config object) let config_arg = call_expr.arguments.first()?; let config_obj = match config_arg { @@ -173,10 +186,16 @@ pub fn extract_pipe_metadata<'a>( Some(metadata) } -/// Find the @Pipe decorator in a list of decorators. +/// Find the @Pipe decorator in a list of decorators. With the file's +/// `consts`, only Angular's (imported from `@angular/core`); without them, any +/// named `Pipe`. pub(crate) fn find_pipe_decorator<'a>( decorators: &'a [Decorator<'a>], + consts: Option<&StringConsts<'_>>, ) -> Option<&'a Decorator<'a>> { + if let Some(consts) = consts { + return crate::directive::find_angular_class_decorator(decorators, "Pipe", consts); + } decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_pipe_call(&call.callee), Expression::Identifier(id) => id.name == "Pipe", @@ -187,9 +206,11 @@ pub(crate) fn find_pipe_decorator<'a>( /// Find the span of the @Pipe decorator on a class. /// /// Returns the span including any leading whitespace/newlines that should be removed -/// along with the decorator. +/// along with the decorator. Without the file's imports, this matches any +/// decorator named `Pipe`; the compiler only takes one imported from +/// `@angular/core`. pub fn find_pipe_decorator_span(class: &Class<'_>) -> Option { - find_pipe_decorator(&class.decorators).map(|d| d.span) + find_pipe_decorator(&class.decorators, None).map(|d| d.span) } /// Check if a callee expression is a call to 'Pipe'. diff --git a/crates/oxc_angular_compiler/src/pipe/mod.rs b/crates/oxc_angular_compiler/src/pipe/mod.rs index 4f91fc7d9..f625450df 100644 --- a/crates/oxc_angular_compiler/src/pipe/mod.rs +++ b/crates/oxc_angular_compiler/src/pipe/mod.rs @@ -17,7 +17,9 @@ mod metadata; pub use compiler::{PipeCompileResult, compile_pipe, compile_pipe_from_metadata}; pub(crate) use decorator::find_pipe_decorator; -pub use decorator::{PipeMetadata, extract_pipe_metadata, find_pipe_decorator_span}; +pub use decorator::{ + PipeMetadata, extract_pipe_metadata, extract_pipe_metadata_in, find_pipe_decorator_span, +}; pub use definition::{ FullPipeDefinition, PipeDefinition, generate_full_pipe_definition_from_decorator, generate_pipe_definition, generate_pipe_definition_from_decorator, diff --git a/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs new file mode 100644 index 000000000..be516bc74 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs @@ -0,0 +1,244 @@ +//! Like ngtsc, a class decorator (`@Component`, `@Directive`, `@Pipe`, +//! `@Injectable`, `@NgModule`) is Angular's only when it's imported from +//! `@angular/core`: by name under any alias (`import {Component as Cmp}`), or +//! through a namespace import (`@ng.Component()`). Another module's, a local or +//! an undeclared one is left on the class, which isn't compiled. One imported +//! through another module that re-exports Angular's (`import {Component} from +//! './shared'`) is left alone too: oxc doesn't follow re-exports. +//! +//! `fixtures/class_decorators_ngtsc.json` is a snapshot of ngtsc's output +//! (`@angular/compiler-cli` 22.1.7) for class `A` in each probe's `test.ts` +//! (the other files are what it imports): its diagnostics, the definitions it +//! gets (`ɵcmp`, `ɵfac`, ...), the decorators left on it and its members, and +//! the decorator types listed in `setClassMetadata`. Cases oxc deliberately +//! handles differently carry a `skip` reason. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, TransformResult, transform_angular_file}; +use serde_json::Value; + +const FIXTURES: &str = include_str!("fixtures/class_decorators_ngtsc.json"); + +fn transform(source: &str, options: &TransformOptions) -> TransformResult { + let allocator = Allocator::default(); + transform_angular_file(&allocator, "test.ts", source, Some(options), None) +} + +/// The `static ɵ...` definitions in `js`, sorted. +fn definitions(js: &str) -> Vec { + let mut defs: Vec = js + .match_indices("static ɵ") + .map(|(at, _)| { + let rest = &js[at + "static ".len()..]; + rest[..rest.find(|c: char| !c.is_alphanumeric() && c != 'ɵ').unwrap()].to_string() + }) + .collect(); + defs.sort(); + defs.dedup(); + defs +} + +/// The decorators (`@X` / `@ns.X`) left in `js`, outside strings and comments, +/// sorted. +fn decorators_left(js: &str) -> Vec { + let mut left = Vec::new(); + for (at, _) in js.match_indices('@') { + let before = js[..at].chars().next_back(); + if before.is_some_and(|c| c.is_alphanumeric() || "_*/'\"`".contains(c)) { + continue; + } + let name: String = js[at + 1..] + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '.') + .collect(); + if name.starts_with(|c: char| c.is_ascii_alphabetic()) { + left.push(name); + } + } + left.sort(); + left +} + +/// The end of the bracketed text opening at `open`, skipping strings. +fn closing(text: &str, open: usize) -> usize { + let bytes = text.as_bytes(); + let (mut depth, mut i) = (0, open); + while i < bytes.len() { + match bytes[i] { + q @ (b'\'' | b'"' | b'`') => { + i += 1; + while bytes[i] != q { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + } + b'[' | b'{' | b'(' => depth += 1, + b']' | b'}' | b')' => { + depth -= 1; + if depth == 0 { + return i; + } + } + _ => {} + } + i += 1; + } + text.len() +} + +/// The decorator types `setClassMetadata(A, [...])` lists in `js`, if it's there. +fn class_metadata(js: &str) -> Option> { + let at = js.find("setClassMetadata(A,")?; + let open = at + js[at..].find('[')?; + let decorators = &js[open..=closing(js, open)]; + let (mut types, mut depth) = (Vec::new(), 0); + let mut i = 0; + while i < decorators.len() { + let rest = &decorators[i..]; + match rest.as_bytes()[0] { + q @ (b'\'' | b'"' | b'`') => { + i += 1; + while decorators.as_bytes()[i] != q { + i += if decorators.as_bytes()[i] == b'\\' { 2 } else { 1 }; + } + } + b'[' | b'{' | b'(' => depth += 1, + b']' | b'}' | b')' => depth -= 1, + _ if depth == 2 && rest.starts_with("type:") => { + let ty = rest["type:".len()..].trim_start(); + let end = ty.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '.').unwrap(); + types.push(ty[..end].to_string()); + } + _ => {} + } + i += 1; + } + Some(types) +} + +fn strings(value: &Value) -> Vec { + value.as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect() +} + +#[test] +fn class_decorators_match_ngtsc() { + let fixtures: Value = serde_json::from_str(FIXTURES).unwrap(); + let mut failures = Vec::new(); + let mut compared = 0; + for fixture in fixtures["fixtures"].as_array().unwrap() { + if fixture.get("skip").is_some() { + continue; + } + compared += 1; + let name = fixture["name"].as_str().unwrap(); + let source = fixture["files"]["test.ts"].as_str().unwrap(); + let result = transform(source, &TransformOptions::default()); + let mut check = |what: &str, ngtsc: String, oxc: String| { + if ngtsc != oxc { + failures.push(format!("{name}\n {what}\n ngtsc: {ngtsc}\n oxc: {oxc}")); + } + }; + + let expected = strings(&fixture["diagnostics"]); + let actual: Vec = result + .diagnostics + .iter() + .filter(|d| d.severity == oxc_diagnostics::Severity::Error) + .map(|d| d.message.to_string()) + .collect(); + check("diagnostics", format!("{expected:?}"), format!("{actual:?}")); + // ngtsc emits nothing for a class it rejects. + if !expected.is_empty() { + continue; + } + check( + "definitions", + format!("{:?}", strings(&fixture["definitions"])), + format!("{:?}", definitions(&result.code)), + ); + check( + "decorators left", + format!("{:?}", strings(&fixture["decoratorsLeft"])), + format!("{:?}", decorators_left(&result.code)), + ); + // oxc lists only the decorator it compiles the class for (ngtsc lists + // all of Angular's: `[Injectable, Component]`), in the same form. + let ngtsc = fixture["classMetadata"].as_array().map(|_| strings(&fixture["classMetadata"])); + let oxc = class_metadata(&result.code); + let listed = match (&ngtsc, &oxc) { + (Some(ngtsc), Some(oxc)) => !oxc.is_empty() && oxc.iter().all(|t| ngtsc.contains(t)), + (None, None) => true, + _ => false, + }; + if !listed { + check("setClassMetadata", format!("{ngtsc:?}"), format!("{oxc:?}")); + } + } + assert!( + failures.is_empty(), + "{} mismatches with ngtsc:\n\n{}", + failures.len(), + failures.join("\n\n") + ); + assert_eq!(compared, 40, "fixtures compared"); +} + +/// ngtsc compiles an aliased `@Injectable` (`import {Injectable as Inj}`) but +/// gives it no factory: its `needsFactory` compares the decorator's written +/// name with `'Injectable'`, so `ɵprov` references a missing `A.ɵfac` (the +/// skipped `injectableAlias` / `aliasNotDecoratorName` probes). oxc gives it +/// the factory, as for `@Injectable`. +#[test] +fn aliased_injectable_gets_a_factory() { + for source in [ + "import {Injectable as Inj} from '@angular/core';\n@Inj({providedIn: 'root'})\nexport class A {}\n", + "import {Injectable as Component} from '@angular/core';\n@Component({providedIn: 'root'})\nexport class A {}\n", + ] { + let result = transform(source, &TransformOptions::default()); + assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics); + assert_eq!(definitions(&result.code), ["ɵfac", "ɵprov"], "{}", result.code); + assert!(decorators_left(&result.code).is_empty(), "{}", result.code); + } +} + +/// In JIT mode, too, only Angular's class decorators are rewritten for the +/// runtime compiler; another library's `@Component` is left as written. +#[test] +fn jit_rewrites_only_angular_class_decorators() { + let options = TransformOptions { jit: true, ..TransformOptions::default() }; + let aliased = transform( + "import {Component as Cmp} from '@angular/core';\n@Cmp({selector: 'a-c', templateUrl: './a.html'})\nexport class A {}\n", + &options, + ); + assert!(aliased.code.contains("__decorate([Cmp({"), "{}", aliased.code); + assert!(!aliased.code.contains("templateUrl"), "{}", aliased.code); + assert!(decorators_left(&aliased.code).is_empty(), "{}", aliased.code); + + let foreign = "import {Component} from './foreign';\n@Component({selector: 'a-c', templateUrl: './a.html'})\nexport class A {}\n"; + let result = transform(foreign, &options); + assert_eq!(result.code, foreign); +} + +/// A declaration an Angular decorator references after the class is hoisted +/// above it, so the definition doesn't read it in its TDZ. Another library's +/// `@Component` isn't compiled, so the file's statements keep their order. +#[test] +fn only_angular_class_decorators_hoist_their_references() { + let source = |import: &str, decorator: &str| { + format!( + "{import}\n@{decorator}({{selector: 'a-c', template: '', providers: [{{provide: TOKEN, useValue: 1}}]}})\nexport class A {{}}\nconst TOKEN = 'tok';\n" + ) + }; + let hoisted = |code: &str| code.find("const TOKEN").unwrap() < code.find("class A").unwrap(); + + let aliased = transform( + &source("import {Component as Cmp} from '@angular/core';", "Cmp"), + &TransformOptions::default(), + ); + assert!(hoisted(&aliased.code), "{}", aliased.code); + + let foreign = transform( + &source("import {Component} from './foreign';", "Component"), + &TransformOptions::default(), + ); + assert!(!hoisted(&foreign.code), "{}", foreign.code); +} diff --git a/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs b/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs index 521ecd862..59d5051f6 100644 --- a/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs +++ b/crates/oxc_angular_compiler/tests/class_metadata_resources_test.rs @@ -881,3 +881,38 @@ export class StyledComponent {} "Error should carry the code and the resource. Got: {rendered}" ); } + +/// An aliased `@Component` (`import { Component as Cmp }`) is compiled, but +/// like ngtsc (`transformDecoratorResources` checks `dec.name`, the name it's +/// written with) its `setClassMetadata` keeps `templateUrl` / `styleUrls`. A +/// namespaced `@ng.Component` is named `Component`, so its resources are +/// inlined. Checked against `@angular/compiler-cli` 22.1.7. +#[test] +fn only_a_decorator_written_as_component_gets_its_resources_inlined() { + let resources = || ResolvedResources { + templates: HashMap::from([("a.html".to_string(), "hi".to_string())]), + styles: HashMap::from([("a.css".to_string(), vec!["b{color:red}".to_string()])]), + }; + let source = |import: &str, decorator: &str| { + format!( + "{import}\n@{decorator}({{selector: 'a-c', templateUrl: 'a.html', styleUrls: ['a.css']}})\nexport class A {{}}\n" + ) + }; + + let aliased = extract_metadata_args(&run_with_resources( + &source("import { Component as Cmp } from '@angular/core';", "Cmp"), + resources(), + )); + assert!(aliased.contains("type:Cmp"), "{aliased}"); + assert!(aliased.contains("templateUrl:\"a.html\""), "{aliased}"); + assert!(aliased.contains("styleUrls:[\"a.css\"]"), "{aliased}"); + + let namespaced = extract_metadata_args(&run_with_resources( + &source("import * as ng from '@angular/core';", "ng.Component"), + resources(), + )); + assert!(namespaced.contains("type:ng.Component"), "{namespaced}"); + assert!(namespaced.contains("template:\"hi\""), "{namespaced}"); + assert!(namespaced.contains("styles:[\"b{color:red}\"]"), "{namespaced}"); + assert!(!namespaced.contains("templateUrl"), "{namespaced}"); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/class_decorators_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/class_decorators_ngtsc.json new file mode 100644 index 000000000..2b3aee150 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/fixtures/class_decorators_ngtsc.json @@ -0,0 +1,520 @@ +{ + "compilerCli": "22.1.7", + "fixtures": [ + { + "name": "named", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Component"] + }, + { + "name": "alias", + "origin": "probe", + "files": { + "test.ts": "import {Component as Cmp} from '@angular/core';\n@Cmp({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Cmp"] + }, + { + "name": "namespace", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["ng.Component"] + }, + { + "name": "importType", + "origin": "probe", + "files": { + "test.ts": "import type {Component} from '@angular/core';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Component"] + }, + { + "name": "typeSpecifier", + "origin": "probe", + "files": { + "test.ts": "import {type Component} from '@angular/core';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Component"] + }, + { + "name": "foreignPkg", + "origin": "probe", + "files": { + "test.ts": "import {Component} from 'foreign-lib';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "node_modules/foreign-lib/package.json": "{\"name\":\"foreign-lib\",\"types\":\"index.d.ts\",\"main\":\"index.js\"}", + "node_modules/foreign-lib/index.d.ts": "export declare function Component(o?: any): ClassDecorator;\nexport declare function Injectable(o?: any): ClassDecorator;\n", + "node_modules/foreign-lib/index.js": "export function Component(){return c=>c}\nexport function Injectable(){return c=>c}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "foreignRel", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './foreign';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "barrel", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './shared';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "shared.ts": "export {Component, Directive, Injectable} from '@angular/core';\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "localFn", + "origin": "probe", + "files": { + "test.ts": "function Component(o: any): ClassDecorator { return (c: any) => c; }\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "localConst", + "origin": "probe", + "files": { + "test.ts": "const Component = (o: any) => (c: any) => c;\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "undeclared", + "origin": "probe", + "files": { + "test.ts": "@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "defaultImport", + "origin": "probe", + "files": { + "test.ts": "import Component from './foreign';\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "foreignNamespace", + "origin": "probe", + "files": { + "test.ts": "import * as other from './foreign';\n@other.Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["other.Component"], + "classMetadata": null + }, + { + "name": "undeclaredNamespace", + "origin": "probe", + "files": { + "test.ts": "@ng.Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["ng.Component"], + "classMetadata": null + }, + { + "name": "ngPlusForeignFoo", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {Foo} from './foreign';\n@Foo()\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": ["Foo"], + "classMetadata": ["Component"] + }, + { + "name": "ngCmpForeignDir", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {Directive} from './foreign';\n@Directive({selector: '[x]'})\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": ["Directive"], + "classMetadata": ["Component"] + }, + { + "name": "foreignCmpNgDir", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {Component} from './foreign';\n@Component({selector: 'a-c', template: ''})\n@Directive({selector: '[x]'})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": ["ɵdir", "ɵfac"], + "decoratorsLeft": ["Component"], + "classMetadata": ["Directive"] + }, + { + "name": "abstractDir", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from '@angular/core';\n@Directive()\nexport abstract class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵdir", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Directive"] + }, + { + "name": "dirAlias", + "origin": "probe", + "files": { + "test.ts": "import {Directive as D, Input} from '@angular/core';\n@D({selector: '[d]'})\nexport class A { @Input() x: any; }\n" + }, + "diagnostics": [], + "definitions": ["ɵdir", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["D"] + }, + { + "name": "injectable", + "origin": "probe", + "files": { + "test.ts": "import {Injectable} from '@angular/core';\n@Injectable({providedIn: 'root'})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵprov"], + "decoratorsLeft": [], + "classMetadata": ["Injectable"] + }, + { + "name": "injectableAlias", + "origin": "probe", + "files": { + "test.ts": "import {Injectable as Inj} from '@angular/core';\n@Inj({providedIn: 'root'})\nexport class A {}\n" + }, + "skip": "ngtsc only gives an aliased `@Injectable` a factory when its local name is `Injectable` (`needsFactory` reads `decorator.name`), so `ɵprov` references a missing `A.ɵfac`; oxc emits the factory", + "diagnostics": [], + "definitions": ["ɵprov"], + "decoratorsLeft": [], + "classMetadata": null + }, + { + "name": "injectableNs", + "origin": "probe", + "files": { + "test.ts": "import * as ng from '@angular/core';\n@ng.Injectable({providedIn: 'root'})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵprov"], + "decoratorsLeft": [], + "classMetadata": ["ng.Injectable"] + }, + { + "name": "injectableForeign", + "origin": "probe", + "files": { + "test.ts": "import {Injectable} from 'foreign-lib';\n@Injectable({providedIn: 'root'})\nexport class A {}\n", + "node_modules/foreign-lib/package.json": "{\"name\":\"foreign-lib\",\"types\":\"index.d.ts\",\"main\":\"index.js\"}", + "node_modules/foreign-lib/index.d.ts": "export declare function Component(o?: any): ClassDecorator;\nexport declare function Injectable(o?: any): ClassDecorator;\n", + "node_modules/foreign-lib/index.js": "export function Component(){return c=>c}\nexport function Injectable(){return c=>c}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Injectable"], + "classMetadata": null + }, + { + "name": "injectableUndeclared", + "origin": "probe", + "files": { + "test.ts": "@Injectable({providedIn: 'root'})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Injectable"], + "classMetadata": null + }, + { + "name": "ngModule", + "origin": "probe", + "files": { + "test.ts": "import {NgModule} from '@angular/core';\n@NgModule({})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵinj", "ɵmod"], + "decoratorsLeft": [], + "classMetadata": ["NgModule"] + }, + { + "name": "ngModuleAlias", + "origin": "probe", + "files": { + "test.ts": "import {NgModule as M} from '@angular/core';\n@M({})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵinj", "ɵmod"], + "decoratorsLeft": [], + "classMetadata": ["M"] + }, + { + "name": "ngModuleForeign", + "origin": "probe", + "files": { + "test.ts": "import {NgModule} from './foreign';\n@NgModule({})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["NgModule"], + "classMetadata": null + }, + { + "name": "pipe", + "origin": "probe", + "files": { + "test.ts": "import {Pipe} from '@angular/core';\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; } }\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵpipe"], + "decoratorsLeft": [], + "classMetadata": ["Pipe"] + }, + { + "name": "pipeAlias", + "origin": "probe", + "files": { + "test.ts": "import {Pipe as P} from '@angular/core';\n@P({name: 'p'})\nexport class A { transform(v: any) { return v; } }\n" + }, + "diagnostics": [], + "definitions": ["ɵfac", "ɵpipe"], + "decoratorsLeft": [], + "classMetadata": ["P"] + }, + { + "name": "pipeForeign", + "origin": "probe", + "files": { + "test.ts": "import {Pipe} from './foreign';\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; } }\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Pipe"], + "classMetadata": null + }, + { + "name": "dirForeign", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from './foreign';\n@Directive({selector: '[d]'})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Directive"], + "classMetadata": null + }, + { + "name": "cmpNgInjForeign", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {Injectable} from './foreign';\n@Injectable()\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": ["Injectable"], + "classMetadata": ["Component"] + }, + { + "name": "cmpNgInjNg", + "origin": "probe", + "files": { + "test.ts": "import {Component, Injectable} from '@angular/core';\n@Injectable()\n@Component({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac", "ɵprov"], + "decoratorsLeft": [], + "classMetadata": ["Injectable", "Component"] + }, + { + "name": "foreignBadInputs", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './foreign';\n@Component({selector: 'a-c', template: '', inputs: [5 as any]})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "ngBadInputs", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\n@Component({selector: 'a-c', template: '', inputs: [5 as any]})\nexport class A {}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is of type 'number'." + ], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "foreignCmpNgInput", + "origin": "probe", + "files": { + "test.ts": "import {Input} from '@angular/core';\nimport {Component} from './foreign';\n@Component({selector: 'a-c', template: ''})\nexport class A { @Input() x: any; }\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component", "Input"], + "classMetadata": null + }, + { + "name": "foreignCmpSignalInput", + "origin": "probe", + "files": { + "test.ts": "import {input} from '@angular/core';\nimport {Component} from './foreign';\n@Component({selector: 'a-c', template: ''})\nexport class A { x = input(0); }\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "skip": "pre-existing: oxc doesn't implement ngtsc's check for `input()` outside a @Component / @Directive class", + "diagnostics": [ + "Unsupported call to the input function. This function can only be used as the initializer of a property on a @Component or @Directive class." + ], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "foreignCmpBadTemplate", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './foreign';\n@Component({selector: 'a-c', template: '
{{ }}'})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "bothImportsNsNg", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './foreign';\nimport * as ng from '@angular/core';\n@ng.Component({selector: 'a-c', template: ''})\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["ng.Component"] + }, + { + "name": "defaultExportAlias", + "origin": "probe", + "files": { + "test.ts": "import {Component as Cmp} from '@angular/core';\n@Cmp({selector: 'a-c', template: ''})\nexport default class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Cmp"] + }, + { + "name": "aliasNotDecoratorName", + "origin": "probe", + "files": { + "test.ts": "import {Injectable as Component} from '@angular/core';\n@Component({providedIn: 'root'})\nexport class A {}\n" + }, + "skip": "like `injectableAlias`: ngtsc gives `@Component` (`Injectable` imported under that name) no factory; oxc compiles it as an `@Injectable` with one", + "diagnostics": [], + "definitions": ["ɵprov"], + "decoratorsLeft": [], + "classMetadata": null + }, + { + "name": "stringImportName", + "origin": "probe", + "files": { + "test.ts": "import {'Component' as Cmp} from '@angular/core';\n@Cmp({selector: 'a-c', template: ''})\nexport class A {}\n" + }, + "diagnostics": [], + "definitions": ["ɵcmp", "ɵfac"], + "decoratorsLeft": [], + "classMetadata": ["Cmp"] + }, + { + "name": "noCall", + "origin": "probe", + "files": { + "test.ts": "import {Component} from '@angular/core';\n@Component\nexport class A {}\n" + }, + "skip": "pre-existing: oxc doesn't report ngtsc's \"@Component is missing a template\" for a bare `@Component`", + "diagnostics": ["@Component is missing a template. Add either a `template` or `templateUrl`"], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + }, + { + "name": "noCallForeign", + "origin": "probe", + "files": { + "test.ts": "import {Component} from './foreign';\n@Component\nexport class A {}\n", + "foreign.ts": "export function Component(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Directive(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Pipe(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Injectable(o?: any): ClassDecorator { return (c: any) => c; }\nexport function NgModule(o?: any): ClassDecorator { return (c: any) => c; }\nexport function Foo(o?: any): ClassDecorator { return (c: any) => c; }\nexport default function Dflt(o?: any): ClassDecorator { return (c: any) => c; }\n" + }, + "diagnostics": [], + "definitions": [], + "decoratorsLeft": ["Component"], + "classMetadata": null + } + ] +} diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 02b7d1c70..423d14429 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1251,7 +1251,7 @@ pub fn extract_pipe_metadata_sync( pipe_name: String, implicit_standalone: Option, ) -> Option { - use oxc_angular_compiler::extract_pipe_metadata; + use oxc_angular_compiler::{collect_string_consts, extract_pipe_metadata_in}; use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; @@ -1262,6 +1262,8 @@ pub fn extract_pipe_metadata_sync( let parser_ret = Parser::new(&allocator, &source, source_type).parse(); let program = &parser_ret.program; + // Only Angular's `@Pipe` (imported from `@angular/core`) counts, as when compiling. + let string_consts = collect_string_consts(&allocator, program); // Find the pipe class by name for stmt in &program.body { @@ -1288,9 +1290,13 @@ pub fn extract_pipe_metadata_sync( } // Extract metadata from @Pipe decorator - if let Some(metadata) = - extract_pipe_metadata(&allocator, class, implicit_standalone, Some(&source)) - { + if let Some(metadata) = extract_pipe_metadata_in( + &allocator, + class, + implicit_standalone, + Some(&source), + Some(&string_consts), + ) { return Some(ExtractedPipeMetadata { class_name: metadata.class_name.to_string(), span_start: metadata.class_span.start, @@ -1328,7 +1334,9 @@ pub fn compile_pipe_sync( implicit_standalone: Option, ) -> PipeCompileResult { use oxc_angular_compiler::output::emitter::JsEmitter; - use oxc_angular_compiler::{R3PipeMetadataBuilder, compile_pipe, extract_pipe_metadata}; + use oxc_angular_compiler::{ + R3PipeMetadataBuilder, collect_string_consts, compile_pipe, extract_pipe_metadata_in, + }; use oxc_ast::ast::{Declaration, ExportDefaultDeclarationKind, Statement}; use oxc_parser::Parser; use oxc_span::SourceType; @@ -1340,6 +1348,8 @@ pub fn compile_pipe_sync( let parser_ret = Parser::new(&allocator, &source, source_type).parse(); let program = &parser_ret.program; + // Only Angular's `@Pipe` (imported from `@angular/core`) counts, as when compiling. + let string_consts = collect_string_consts(&allocator, program); // Find the pipe class by name for stmt in &program.body { @@ -1366,9 +1376,13 @@ pub fn compile_pipe_sync( } // Extract metadata from @Pipe decorator - if let Some(metadata) = - extract_pipe_metadata(&allocator, class, implicit_standalone, Some(&source)) - { + if let Some(metadata) = extract_pipe_metadata_in( + &allocator, + class, + implicit_standalone, + Some(&source), + Some(&string_consts), + ) { // Create type expression for the pipe class use oxc_allocator::Box; use oxc_angular_compiler::output::ast::{OutputExpression, ReadVarExpr}; @@ -2032,8 +2046,12 @@ pub fn compile_class_metadata_sync( }; }; - // Find the target decorator on the class - let target_decorator = find_angular_decorator(&class.decorators, &decorator_type); + // Find the target decorator on the class. Like the compiler, only Angular's + // counts (imported from `@angular/core`), and signal members get a synthetic + // decorator only when they call Angular's `input()`, `viewChild()`, ... + let string_consts = oxc_angular_compiler::collect_string_consts(&allocator, program); + let target_decorator = + find_angular_decorator(&class.decorators, &decorator_type, &string_consts); let Some(decorator) = target_decorator else { return ClassMetadataNapiCompileResult { @@ -2076,10 +2094,7 @@ pub fn compile_class_metadata_sync( Some(&source), ); - // Build property decorators metadata. Signal members get a synthetic - // decorator only when they call Angular's `input()`, `viewChild()`, ..., - // which the file's imports tell. - let string_consts = oxc_angular_compiler::collect_string_consts(&allocator, program); + // Build property decorators metadata. let prop_decorators_expr = core_build_prop_decorators_metadata( &allocator, class, @@ -2147,23 +2162,34 @@ pub fn compile_class_metadata( AsyncTask::new(CompileClassMetadataTask { source, file_path, class_name, decorator_type }) } -/// Find an Angular decorator by name on a class. +/// Find the Angular decorator `decorator_name` on a class. `Component`, +/// `Directive`, `Pipe`, `Injectable` and `NgModule` count only when imported from +/// `@angular/core` (under any alias, or through a namespace import), like the +/// compiler; any other name (`Service`) is matched by name. fn find_angular_decorator<'a>( decorators: &'a [oxc_ast::ast::Decorator<'a>], decorator_name: &str, + string_consts: &oxc_angular_compiler::StringConsts<'_>, ) -> Option<&'a oxc_ast::ast::Decorator<'a>> { + if matches!(decorator_name, "Component" | "Directive" | "Pipe" | "Injectable" | "NgModule") { + return oxc_angular_compiler::find_angular_class_decorator( + decorators, + decorator_name, + string_consts, + ); + } decorators.iter().find(|d| { match &d.expression { oxc_ast::ast::Expression::CallExpression(call) => match &call.callee { - // Direct call: @Component() + // Direct call: @Service() oxc_ast::ast::Expression::Identifier(id) => id.name == decorator_name, - // Namespaced: @ng.Component() or @core.Component() + // Namespaced: @ng.Service() or @core.Service() oxc_ast::ast::Expression::StaticMemberExpression(member) => { member.property.name == decorator_name } _ => false, }, - // Decorator without call: @Component (rare, usually invalid) + // Decorator without call: @Service (rare, usually invalid) oxc_ast::ast::Expression::Identifier(id) => id.name == decorator_name, _ => false, } diff --git a/napi/angular-compiler/test/extract-component-metadata.test.ts b/napi/angular-compiler/test/extract-component-metadata.test.ts index 4b2a4d684..4226accc7 100644 --- a/napi/angular-compiler/test/extract-component-metadata.test.ts +++ b/napi/angular-compiler/test/extract-component-metadata.test.ts @@ -144,3 +144,28 @@ export class X { ]) }) }) + +// Like the compiler (and ngtsc), only Angular's `@Component` counts: imported +// from `@angular/core` under any name or through a namespace import. +describe('extractComponentMetadataSync class decorators', () => { + it("lists the classes Angular's @Component decorates, not another library's", () => { + const components = extractComponentMetadataSync( + ` +import { Component as Cmp } from '@angular/core'; +import * as ng from '@angular/core'; +import { Component } from './widgets'; + +@Cmp({ selector: 'app-a', template: '' }) +export class A {} + +@ng.Component({ selector: 'app-b', template: '' }) +export class B {} + +@Component({ selector: 'x-widget', template: '' }) +export class Widget {} +`, + 'x.component.ts', + ) + expect(components.map((c) => c.className)).toEqual(['A', 'B']) + }) +}) diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index cc00e8aff..7f0f7c891 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -569,6 +569,45 @@ describe('pendingHmrUpdates race condition', () => { expect(fullReload, 'expected a full-reload event').toBeDefined() }) + it("triggers full reload when only another library's @Component template changes", async () => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + // `Widget`'s `@Component` comes from another library, so it isn't + // compiled: its template isn't one HMR can replace. + const mixedPath = join(appDir, 'mixed.component.ts') + const source = ` + import * as ng from '@angular/core'; + import { Component } from './widgets'; + @ng.Component({ selector: 'app-first', template: '
First
' }) + export class FirstComponent {} + @Component({ selector: 'x-widget', template: '
Widget
' }) + export class Widget {} + ` + writeFileSync(mixedPath, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + source, + mixedPath, + ) + + writeFileSync(mixedPath, source.replace('
Widget
', '
Widget Edited
')) + + const ctx = createMockHmrContext(mixedPath, [{ id: mixedPath }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const componentUpdates = mockServer._wsMessages.filter( + (m: any) => m.event === 'angular:component-update', + ) + expect(componentUpdates).toHaveLength(0) + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + expect(fullReload, 'expected a full-reload event').toBeDefined() + }) + it('consumes pending entry and dispatches angular:invalidate on compile error', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) diff --git a/napi/angular-compiler/test/plugin.test.ts b/napi/angular-compiler/test/plugin.test.ts index 9f85de9e8..07328d3f7 100644 --- a/napi/angular-compiler/test/plugin.test.ts +++ b/napi/angular-compiler/test/plugin.test.ts @@ -198,3 +198,45 @@ describe('@oxc-angular/vite auto component style minification', () => { expect(code).toContain('.container[_ngcontent-%COMP%] { color: red; background: transparent; }') }) }) + +describe('@oxc-angular/vite decorator pre-check', () => { + // The compiler takes a class decorator imported from `@angular/core` under + // any name, so the quick text check that skips files without Angular + // decorators must not skip `@Cmp(...)` or `@ng.Component(...)`. + async function transform(source: string): Promise { + const plugin = getAngularPlugin() + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + const result = await plugin.transform.handler.call( + { + error(message: string) { + throw new Error(message) + }, + warn() {}, + } as any, + source, + 'app.component.ts', + ) + return result && typeof result === 'object' && 'code' in result + ? (result.code as string) + : undefined + } + + it.each([ + [ + 'an aliased import', + `import { Component as Cmp } from '@angular/core'; +@Cmp({ selector: 'app-root', template: '' }) +export class AppComponent {}`, + ], + [ + 'a namespace import', + `import * as ng from '@angular/core'; +@ng.Component({ selector: 'app-root', template: '' }) +export class AppComponent {}`, + ], + ])('compiles a component decorated through %s', async (_, source) => { + expect(await transform(source)).toContain('ɵɵdefineComponent') + }) +}) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 0acf9bb74..35e663894 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -794,7 +794,8 @@ export function angular(options: PluginOptions = {}): Plugin[] { const cachedStripped = componentMetadataCache.get(resolvedId) return ( cachedStripped !== undefined && - cachedStripped === stripComponentMetadata(source) + cachedStripped === + stripComponentMetadata(source, componentsByFile.get(resolvedId)) ) } const styles: string[] | null = @@ -875,14 +876,19 @@ export function angular(options: PluginOptions = {}): Plugin[] { } // Quick check for Angular decorators - avoids parsing files without them - // OXC handles @Component, @Directive, @NgModule, @Injectable, @Pipe, @Service + // OXC handles @Component, @Directive, @NgModule, @Injectable, @Pipe, @Service. + // The compiler also takes one imported from `@angular/core` under another + // name (`@Cmp(...)`, `@ng.Component(...)`); such a file still names + // `@angular/core` and the decorator. const hasAngularDecorator = code.includes('@Component') || code.includes('@Directive') || code.includes('@NgModule') || code.includes('@Injectable') || code.includes('@Pipe') || - code.includes('@Service') + code.includes('@Service') || + (code.includes('@angular/core') && + /\b(?:Component|Directive|NgModule|Injectable|Pipe|Service)\b/.test(code)) if (!hasAngularDecorator) { return } @@ -1049,7 +1055,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // Cache the metadata-stripped (whole-file) source for cheaply // diffing whether anything besides template/styles changed. - componentMetadataCache.set(actualId, stripComponentMetadata(code)) + componentMetadataCache.set(actualId, stripComponentMetadata(code, classNamesInFile)) } return { @@ -1296,7 +1302,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } catch { newContent = '' } - const newStripped = stripComponentMetadata(newContent) + const newStripped = stripComponentMetadata(newContent, fileClassNames) if (newStripped === cachedStripped) { debugHmr('inline template/styles-only change, dispatching HMR for %s', ctx.file) componentMetadataCache.set(ctx.file, newStripped) @@ -1455,14 +1461,19 @@ export function angular(options: PluginOptions = {}): Plugin[] { } /** - * Empty the `template:` and `styles:` field values of *every* `@Component(...)` - * in the source, returning the result. Used to detect "only template/styles - * changed somewhere in the file" — if the stripped form of the old and new - * source is byte-identical, the diff is contained within those fields and we - * can dispatch HMR (one event per component in the file) instead of a full - * reload. + * Empty the `template:` and `styles:` field values of every `@Component(...)` + * in the source that decorates one of `componentClassNames` (the classes the + * compiler compiled as components), returning the result. Used to detect "only + * template/styles changed somewhere in the file" — if the stripped form of the + * old and new source is byte-identical, the diff is contained within those + * fields and we can dispatch HMR (one event per component in the file) instead + * of a full reload. A `@Component` the compiler didn't take (another library's) + * is left whole, so a change to it reloads the page. */ -function stripComponentMetadata(code: string): string { +function stripComponentMetadata( + code: string, + componentClassNames: ReadonlySet | undefined, +): string { // Enumerate decorators ONCE (O(N) walk of source) and look up each one's // template + styles range directly from its argsRange. Calling the // className-based locators per decorator would re-enumerate inside each, @@ -1473,6 +1484,7 @@ function stripComponentMetadata(code: string): string { const decorators = locateComponentDecorators(code) const ranges: Array<[number, number]> = [] for (const d of decorators) { + if (!componentClassNames?.has(d.className)) continue const tpl = locateTemplateInArgs(code, d.argsRange) if (tpl) ranges.push(tpl) const styles = locateStylesInArgs(code, d.argsRange) From 7a8cc105e3c030ddc14e7d9cc23c1b943b0d051d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 11:32:54 +0800 Subject: [PATCH 09/12] fix(directive): report a transform computed from an import instead of assuming it's a function Only an imported function named directly (`transform: fn`, or one an expression passes through unchanged, like `0 || fn`) is assumed to be a function. A value computed from an import (`!FLAG`, `make()`, `Helpers.fn`, `FLAG ? a : b`, `fn || a`, `FLAG && a`) now gets the "imported from another module" error in both `inputs:` and `@Input`, where it used to be emitted as written with no diagnostic (`!FLAG` is a boolean: a TypeError at runtime). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/decorator.rs | 41 +++++++++--- .../src/directive/evaluator.rs | 9 ++- .../tests/decorator_metadata_ngtsc_test.rs | 67 +++++++++++++++---- 3 files changed, 93 insertions(+), 24 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index fab81818d..a0ff7b20c 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -743,6 +743,22 @@ fn scoped_transform_error( } } +/// oxc's error for a transform computed from an import (`!FLAG`, `make()`, +/// `FLAG && fn`, `Helpers.fn`): ngtsc evaluates it with the import's file, oxc +/// can't (see [`value_error`]). An imported function named directly +/// (`transform: fn`) needs no evaluating: it's assumed to be a function. +fn imported_transform_error( + subject: &str, + transform: &Prop<'_>, + span: Span, +) -> Option<(String, Span)> { + let computed = matches!( + transform.value, + Value::Reference { kind: RefKind::Import { namespace_member: false, local: None }, .. } + ); + computed.then(|| (value_error(subject, String::new, &transform.value), span)) +} + /// Whether [`transform_expression`] has no expression to emit for a transform /// it was given, because it uses the parameters of the function it's written /// in (see [`scoped_transform_error`]). @@ -927,16 +943,18 @@ fn parse_input_object<'a>( let (transform_function, error) = match item.prop("transform") { Some(transform) => { let expr = transform_expression(allocator, transform, source_text, consts); - let error = - transform_error(transform, Some(position), name, class, consts.scope(), span) - .or_else(|| match transform { - Prop { expr: Some(written), .. } if is_out_of_scope(transform, consts) => { - let message = - scoped_transform_error("@Directive.inputs", name, written, consts); - Some((message, span)) - } - _ => None, - }); + let error = imported_transform_error("@Directive.inputs", transform, span) + .or_else(|| { + transform_error(transform, Some(position), name, class, consts.scope(), span) + }) + .or_else(|| match transform { + Prop { expr: Some(written), .. } if is_out_of_scope(transform, consts) => { + let message = + scoped_transform_error("@Directive.inputs", name, written, consts); + Some((message, span)) + } + _ => None, + }); (expr, error) } None => (None, None), @@ -1226,6 +1244,9 @@ fn input_decorator_error<'a>( return Some((message, decorator.span)); } let transform = options.prop("transform")?; + if let Some(error) = imported_transform_error(&subject, transform, span) { + return Some(error); + } transform_error(transform, None, name, class, consts.scope(), span).or_else( || match transform { Prop { expr: Some(written), .. } if is_out_of_scope(transform, consts) => { diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 983f27530..8fc6710a4 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -2611,8 +2611,13 @@ pub(crate) fn transform_error<'a>( } // An imported function can't be inspected from this file, so whether // it's generic or overloaded is unknown; the name clash is checked - // after those. - Value::Reference { kind: RefKind::Import { namespace_member: false, .. }, .. } => { + // after those. Only the import itself is assumed to be a function, not + // a value computed from it (`!FLAG`, `make()`), which the caller + // reports as imported. + Value::Reference { + kind: RefKind::Import { namespace_member: false, local: Some(_) }, + .. + } => { return clash(); } // ngtsc can't name `ns.f` in the compiled file. diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index e97b545e3..52999089b 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -384,25 +384,68 @@ export class Dir {} assert!(errors(&result, source).is_empty(), "{:?}", errors(&result, source)); assert!(strip(&result.code).contains(r#"inputs:{x:[2,"x","x",fn]}"#), "{}", result.code); - // A value computed from an import (a call, a member, a condition on one) - // isn't the import: it has no name in this file, so it's kept as written, - // never replaced by the import's name. - for (transform_expr, emitted) in - [("fn()", "fn()"), ("fn.f", "fn.f"), ("fn ? a : a", "fn?a:a"), ("SHARED || a", "SHARED||a")] - { + // A value computed from an import (`!FLAG`, a call, a member, a condition + // or a deciding `&&` / `||`) isn't the import: ngtsc evaluates it with + // the other file (`!FLAG` is a boolean, ngtsc 22.1.7: "must be a + // function"; `fn || a` is `fn`), oxc can't, so it's reported, in + // `inputs:` and in `@Input`. An import the expression only passes through + // (`0 || fn`) is that import, as above. + let cases = [ + ("!FLAG", "FLAG"), + ("fn()", "fn"), + ("fn.f", "fn"), + ("FLAG ? a : a", "FLAG"), + ("fn || a", "fn"), + ("FLAG && a", "FLAG"), + ]; + for (transform_expr, name) in cases { + for (meta, member, subject, span) in [ + ( + format!("inputs: [{{name: 'x', transform: {transform_expr}}}]"), + "x: any;".to_string(), + "@Directive.inputs", + format!("[{{name: 'x', transform: {transform_expr}}}]"), + ), + ( + String::new(), + format!("@Input({{transform: {transform_expr}}}) x: any;"), + "@Input", + format!("{{transform: {transform_expr}}}"), + ), + ] { + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +import {{fn, FLAG}} from './shared'; +export function a(v: string) {{ return 1; }} +@Directive({{selector: '[d]', {meta}}}) +export class Dir {{ + {member} +}} +" + ); + let message = format!( + "{subject} depends on '{name}', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files." + ); + assert_eq!(errors(&transform(&source), &source), vec![(message, span)], "{source}"); + } + } + for transform_expr in ["0 || fn", "a && fn", "1 ? fn : a"] { let source = format!( - "import {{Directive}} from '@angular/core'; -import {{fn, SHARED}} from './shared'; + "import {{Directive, Input}} from '@angular/core'; +import {{fn}} from './shared'; export function a(v: string) {{ return 1; }} @Directive({{selector: '[d]', inputs: [{{name: 'x', transform: {transform_expr}}}]}}) -export class Dir {{}} +export class Dir {{ + x: any; + @Input({{transform: {transform_expr}}}) y: any; +}} " ); let result = transform(&source); + assert!(errors(&result, &source).is_empty(), "{:?}", errors(&result, &source)); let code = strip(&result.code); - let bare = format!(r#"inputs:{{x:[2,"x","x",{emitted}]}}"#); - let wrapped = format!(r#"inputs:{{x:[2,"x","x",({emitted})]}}"#); - assert!(code.contains(&bare) || code.contains(&wrapped), "{code}"); + assert!(code.contains(r#"inputs:{x:[2,"x","x",fn],y:[2,"y","y",fn]}"#), "{code}"); } } From fd7a55d9b9afe00fea42e54c6270272e7aa02b79 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 13:42:19 +0800 Subject: [PATCH 10/12] fix(vite): find the compiled `@Component` for the HMR diff by the name the compiler reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HMR check empties the inline `template:` / `styles:` of each compiled component and compares the rest of the file. It found the decorator by looking for the literal `@Component(`, so a component compiled through `import { Component as Cmp }` (`@Cmp(...)`) or `import * as ng` (`@ng.Component(...)`) was never emptied, and an inline template or styles edit to it reloaded the page instead of hot-replacing it. The compiler now reports, with HMR on, each compiled component's decorator as written (`componentDecorators`: component ID → `Component`, `Cmp`, `ng.Component`, ...). The plugin looks for exactly that spelling on that class, so the text scan never decides on its own which decorators are Angular's. Only the decorator the compiler took is emptied: another library's `@Component`, even on the same class, is left whole and an edit to it still reloads. A spelling the scan can't match (`@Cmp(`, `@ Cmp(`, a comment before `(`) is never emptied, so edits to it reload, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 28 +++++- .../tests/class_decorator_identity_test.rs | 13 +++ napi/angular-compiler/index.d.ts | 5 + napi/angular-compiler/src/lib.rs | 6 ++ .../test/decorator-fields.test.ts | 26 ++++++ .../test/hmr-hot-update.test.ts | 92 +++++++++++++++++++ napi/angular-compiler/vite-plugin/index.ts | 49 +++++++--- .../vite-plugin/utils/decorator-fields.ts | 68 ++++++++++---- 8 files changed, 247 insertions(+), 40 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 815f5f553..009168b8b 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -309,6 +309,13 @@ pub struct TransformResult { /// Maps component ID to list of styles. pub style_updates: HashMap>, + /// For HMR: maps the ID of each compiled component (path@ClassName) to its + /// `@Component` decorator's callee as written in the source (`Component`, + /// `Cmp` for `import { Component as Cmp }`, `ng.Component` for a namespace + /// import). It lets a build tool find the decorator the compiler took + /// without deciding again which decorators are Angular's. + pub component_decorators: HashMap, + /// Compilation diagnostics (errors and warnings). pub diagnostics: Vec, @@ -2677,20 +2684,31 @@ pub fn transform_angular_file( let component_id = format!("{}@{}", path, class_name); + let component_decorator = + find_component_decorator(&class.decorators, &string_consts); + // Store for HMR if enabled if options.hmr { result.template_updates.insert( component_id.clone(), compilation_result.template_js.clone(), ); + if let Some(decorator) = component_decorator { + let callee = match &decorator.expression { + Expression::CallExpression(call) => call.callee.span(), + expression => expression.span(), + }; + result.component_decorators.insert( + component_id.clone(), + source[callee.start as usize..callee.end as usize] + .to_string(), + ); + } } // Track the decorator span to remove - if let Some(span) = - find_component_decorator(&class.decorators, &string_consts) - .map(|d| d.span) - { - decorator_spans_to_remove.push(span); + if let Some(decorator) = component_decorator { + decorator_spans_to_remove.push(decorator.span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans( diff --git a/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs index be516bc74..4c2124b1d 100644 --- a/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs +++ b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs @@ -242,3 +242,16 @@ fn only_angular_class_decorators_hoist_their_references() { ); assert!(!hoisted(&foreign.code), "{}", foreign.code); } + +/// With HMR on, each compiled component's `@Component` is reported as written, +/// so a build tool can find the decorator the compiler took. Another library's +/// `@Component` isn't compiled, so it isn't reported. +#[test] +fn hmr_reports_component_decorators_as_written() { + let source = "import * as ng from '@angular/core';\nimport {Component as Cmp} from '@angular/core';\nimport {Component} from './foreign';\n@Cmp({selector: 'a-c', template: ''})\nexport class A {}\n@ng . Component({selector: 'b-c', template: ''})\nexport class B {}\n@Component({selector: 'c-c', template: ''})\nexport class C {}\n"; + let result = transform(source, &TransformOptions { hmr: true, ..TransformOptions::default() }); + let mut reported: Vec<(&str, &str)> = + result.component_decorators.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + reported.sort_unstable(); + assert_eq!(reported, [("test.ts@A", "Cmp"), ("test.ts@B", "ng . Component")]); +} diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index f36a7c73a..a9a6471d5 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -907,6 +907,11 @@ export interface TransformResult { templateUpdates: Map /** Style updates for HMR (component_id → styles). */ styleUpdates: Map + /** + * For HMR: each compiled component's `@Component` decorator callee as + * written (component_id → `Component`, `Cmp`, `ng.Component`, ...). + */ + componentDecorators: Record /** Compilation errors. */ errors: Array /** Compilation warnings. */ diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 423d14429..533ef3a94 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -355,6 +355,11 @@ pub struct TransformResult { #[napi(ts_type = "Map")] pub style_updates: HashMap>, + /// For HMR: each compiled component's `@Component` decorator callee as + /// written (component_id → `Component`, `Cmp`, `ng.Component`, ...). + #[napi(ts_type = "Record")] + pub component_decorators: HashMap, + /// Compilation errors. pub errors: Vec, @@ -1145,6 +1150,7 @@ impl Task for TransformAngularFileTask { dependencies: result.dependencies, template_updates: result.template_updates, style_updates: result.style_updates, + component_decorators: result.component_decorators, errors, warnings: vec![], dts_declarations: result diff --git a/napi/angular-compiler/test/decorator-fields.test.ts b/napi/angular-compiler/test/decorator-fields.test.ts index d17baef83..37532ec0d 100644 --- a/napi/angular-compiler/test/decorator-fields.test.ts +++ b/napi/angular-compiler/test/decorator-fields.test.ts @@ -233,6 +233,32 @@ describe('decorator-fields utils', () => { const out = locateComponentDecorators(src) expect(out.map((d) => d.className)).toEqual(['A', 'B']) }) + + // The compiler reports how it found each component's decorator spelled; + // the scan looks for exactly those spellings, whole. + it('finds the decorator spellings it is given, and only those', () => { + const src = ` + @Cmp({ template: 'a' }) + class A {} + @ng.Component({ template: 'b' }) + class B {} + @Component({ template: 'c' }) + class C {} + @CmpX({ template: 'd' }) + class D {} + @Cmp.x({ template: 'e' }) + class E {} + @ng . Component ({ template: 'f' }) + class F {} + ` + const out = locateComponentDecorators(src, ['Cmp', 'ng.Component', 'ng . Component']) + expect(out.map((d) => [d.className, d.decorator])).toEqual([ + ['A', 'Cmp'], + ['B', 'ng.Component'], + ['F', 'ng . Component'], + ]) + expect(locateComponentDecorators(src).map((d) => d.className)).toEqual(['C']) + }) }) describe('locateStylesInArgs', () => { diff --git a/napi/angular-compiler/test/hmr-hot-update.test.ts b/napi/angular-compiler/test/hmr-hot-update.test.ts index 7f0f7c891..0887c51cb 100644 --- a/napi/angular-compiler/test/hmr-hot-update.test.ts +++ b/napi/angular-compiler/test/hmr-hot-update.test.ts @@ -608,6 +608,98 @@ describe('pendingHmrUpdates race condition', () => { expect(fullReload, 'expected a full-reload event').toBeDefined() }) + // The compiler takes a `@Component` imported from `@angular/core` under any + // name, so an inline template or styles edit to one HMRs whatever the + // decorator is spelled as. + it.each([ + ['a named import', `import { Component } from '@angular/core';`, 'Component'], + ['an aliased import', `import { Component as Cmp } from '@angular/core';`, 'Cmp'], + ['a namespace import', `import * as ng from '@angular/core';`, 'ng.Component'], + ['a spaced namespace import', `import * as ng from '@angular/core';`, 'ng . Component'], + ])( + 'dispatches HMR for an inline template / styles edit of a component decorated through %s', + async (_, importLine, decorator) => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const path = join(appDir, `spelled-${decorator.replace(/\W/g, '')}.component.ts`) + const source = ` + ${importLine} + @${decorator}({ selector: 'app-first', template: '
First
', styles: ['.a{}'] }) + export class FirstComponent {} + ` + writeFileSync(path, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + source, + path, + ) + + writeFileSync( + path, + source.replace('
First
', '
Edited
').replace('.a{}', '.b{}'), + ) + const ctx = createMockHmrContext(path, [{ id: path }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const componentIds = mockServer._wsMessages + .filter((m: any) => m.event === 'angular:component-update') + .map((m: any) => decodeURIComponent(m.data.id)) + expect(componentIds).toEqual([`${path}@FirstComponent`]) + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + expect(fullReload, 'expected no full-reload for inline template change').toBeUndefined() + }, + ) + + // Only the decorator the compiler took is emptied: another library's + // `@Component` on the same class keeps its template, so editing it reloads. + it.each([ + ['the compiled decorator', '
Angular
', 'hmr'], + ["another library's decorator on the same class", '
Widget
', 'reload'], + ])('edit to the template of %s', async (_, edited, expected) => { + const plugin = getAngularPlugin() + const mockServer = await setupPluginWithServer(plugin) + + const path = join(appDir, `two-decorators-${expected}.component.ts`) + const source = ` + import * as ng from '@angular/core'; + import { Component } from './widgets'; + @Component({ selector: 'x-widget', template: '
Widget
' }) + @ng.Component({ selector: 'app-first', template: '
Angular
' }) + export class FirstComponent {} + ` + writeFileSync(path, source) + + if (!plugin.transform || typeof plugin.transform === 'function') { + throw new Error('Expected plugin transform handler') + } + await plugin.transform.handler.call( + { error() {}, warn() {}, addWatchFile() {} } as any, + source, + path, + ) + + writeFileSync(path, source.replace(edited, '
Edited
')) + const ctx = createMockHmrContext(path, [{ id: path }], mockServer) + await callHandleHotUpdate(plugin, ctx) + + const updates = mockServer._wsMessages.filter( + (m: any) => m.event === 'angular:component-update', + ) + const fullReload = mockServer._wsMessages.find((m: any) => m.type === 'full-reload') + if (expected === 'hmr') { + expect(updates).toHaveLength(1) + expect(fullReload).toBeUndefined() + } else { + expect(updates).toHaveLength(0) + expect(fullReload).toBeDefined() + } + }) + it('consumes pending entry and dispatches angular:invalidate on compile error', async () => { const plugin = getAngularPlugin() const mockServer = await setupPluginWithServer(plugin) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 35e663894..cb34c4e0f 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -267,6 +267,12 @@ export function angular(options: PluginOptions = {}): Plugin[] { // emits per-component HMR updates and we mirror that here. const componentsByFile = new Map>() + // For each component .ts file, each compiled component's `@Component` + // decorator as the compiler reports it (class name → `Component`, `Cmp`, + // `ng.Component`, ...), so the template/styles strip below finds exactly the + // decorators the compiler took, under whatever name they're imported. + const componentDecoratorsByFile = new Map>() + // Reverse mapping: resource file path → component file path. Single-valued: // the last transform to reference a resource owns the slot. Multiple // components in the SAME file are covered by `dispatchAllComponentsInFile`; @@ -795,7 +801,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { return ( cachedStripped !== undefined && cachedStripped === - stripComponentMetadata(source, componentsByFile.get(resolvedId)) + stripComponentMetadata(source, componentDecoratorsByFile.get(resolvedId)) ) } const styles: string[] | null = @@ -1028,10 +1034,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { dependencies, ) const classNamesInFile = new Set() + const decoratorsInFile = new Map() for (const componentId of templateUpdateKeys) { const atIdx = componentId.indexOf('@') if (atIdx === -1) continue - classNamesInFile.add(componentId.slice(atIdx + 1)) + const className = componentId.slice(atIdx + 1) + classNamesInFile.add(className) + const decorator = result.componentDecorators[componentId] + if (decorator !== undefined) decoratorsInFile.set(className, decorator) } // Prune pending updates for components that USED to be in this // file but no longer are (e.g. a class was renamed or removed). @@ -1049,13 +1059,14 @@ export function angular(options: PluginOptions = {}): Plugin[] { } componentsByFile.set(actualId, classNamesInFile) + componentDecoratorsByFile.set(actualId, decoratorsInFile) for (const className of classNamesInFile) { debugHmr('registered: %s -> %s', actualId, className) } // Cache the metadata-stripped (whole-file) source for cheaply // diffing whether anything besides template/styles changed. - componentMetadataCache.set(actualId, stripComponentMetadata(code, classNamesInFile)) + componentMetadataCache.set(actualId, stripComponentMetadata(code, decoratorsInFile)) } return { @@ -1273,6 +1284,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // file (e.g. an external template change just invalidated the .ts // module via the graph), the resource branch has it covered. const fileClassNames = componentsByFile.get(ctx.file)! + const fileDecorators = componentDecoratorsByFile.get(ctx.file) let alreadyPending = false for (const className of fileClassNames) { if (pendingHmrUpdates.has(`${ctx.file}@${className}`)) { @@ -1302,7 +1314,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } catch { newContent = '' } - const newStripped = stripComponentMetadata(newContent, fileClassNames) + const newStripped = stripComponentMetadata(newContent, fileDecorators) if (newStripped === cachedStripped) { debugHmr('inline template/styles-only change, dispatching HMR for %s', ctx.file) componentMetadataCache.set(ctx.file, newStripped) @@ -1461,18 +1473,24 @@ export function angular(options: PluginOptions = {}): Plugin[] { } /** - * Empty the `template:` and `styles:` field values of every `@Component(...)` - * in the source that decorates one of `componentClassNames` (the classes the - * compiler compiled as components), returning the result. Used to detect "only - * template/styles changed somewhere in the file" — if the stripped form of the - * old and new source is byte-identical, the diff is contained within those - * fields and we can dispatch HMR (one event per component in the file) instead - * of a full reload. A `@Component` the compiler didn't take (another library's) - * is left whole, so a change to it reloads the page. + * Empty the `template:` and `styles:` field values of each component's + * `@Component(...)` in `components` (class name → the decorator as the compiler + * reported it: `Component`, `Cmp`, `ng.Component`), returning the result. Used + * to detect "only template/styles changed somewhere in the file" — if the + * stripped form of the old and new source is byte-identical, the diff is + * contained within those fields and we can dispatch HMR (one event per + * component in the file) instead of a full reload. + * + * Only the decorator the compiler took is emptied, found by the spelling it + * reported, so this never decides on its own which decorators are Angular's. + * Another library's `@Component`, or another decorator of the same class, is + * left whole, so a change to it reloads the page. The new source is stripped + * with the old spellings: a change that could alter them (an import) is outside + * the emptied fields, so it already makes the two forms differ. */ function stripComponentMetadata( code: string, - componentClassNames: ReadonlySet | undefined, + components: ReadonlyMap | undefined, ): string { // Enumerate decorators ONCE (O(N) walk of source) and look up each one's // template + styles range directly from its argsRange. Calling the @@ -1481,10 +1499,11 @@ function stripComponentMetadata( // // Splice from highest start → lowest so earlier offsets stay valid as we // mutate the string from the end backwards. - const decorators = locateComponentDecorators(code) + if (!components || components.size === 0) return code + const decorators = locateComponentDecorators(code, components.values()) const ranges: Array<[number, number]> = [] for (const d of decorators) { - if (!componentClassNames?.has(d.className)) continue + if (components.get(d.className) !== d.decorator) continue const tpl = locateTemplateInArgs(code, d.argsRange) if (tpl) ranges.push(tpl) const styles = locateStylesInArgs(code, d.argsRange) diff --git a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts index 20a2bf641..9f818a302 100644 --- a/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts +++ b/napi/angular-compiler/vite-plugin/utils/decorator-fields.ts @@ -21,9 +21,12 @@ * distinguish `/` as a division operator from `/` as a regex-literal * opener without a real JS lexer. Regex literals inside @Component * args don't appear in real Angular code, so this is accepted. - * - **Aliased decorator imports**: `@core.Component(...)` or - * `import { Component as C }` followed by `@C({...})`. Only the - * literal `@Component` form is recognized. + * - **Which decorators are Angular's**: this scan does not read imports. + * It finds a decorator by the spelling the caller passes, which the + * compiler reports for each component it compiled (`Component`, `Cmp` + * for `import { Component as Cmp }`, `ng.Component`). A spelling with + * whitespace or a comment between `@` and the name, or type arguments + * before `(`, is not found, so its fields are never emptied. * - **Parenthesized decorator expressions** like `@(Component as any)(...)` * — uncommon and not supported. * - **Computed property keys** (`{ ['styles']: [...] }`) can't be resolved @@ -71,8 +74,8 @@ const ASCII_WORD_RE = /[A-Za-z0-9_$]/ const IDENT_START_RE = /[\p{L}_$]/u const IDENT_CONT_RE = /[\p{L}\p{N}_$]/u -/** The only decorator form recognized — see the module docstring. */ -const DECORATOR_NAME = 'Component' +/** The decorator spelling looked for when the caller names none. */ +const DEFAULT_DECORATOR_NAMES: readonly string[] = ['Component'] /** Opener chars accepted as the value of `styles:` — `string | string[]`. */ const STYLES_OPENERS = '\'"`[' @@ -476,11 +479,14 @@ export interface ComponentDecorator { argsRange: [number, number] /** The class name declared after this decorator. */ className: string + /** The decorator as spelled before its `(`: `Component`, `Cmp`, `ng.Component`. */ + decorator: string } /** * Enumerate every `@Component(...)` decorator in `code`, pairing each with - * the class declared immediately after it. Decorators that don't pair to a + * the class declared immediately after it. `decoratorNames` are the spellings + * to look for (`Cmp` finds `@Cmp(...)`); by default only `@Component(...)`. Decorators that don't pair to a * class (dangling, malformed, anonymous) are skipped — the caller sees only * well-formed component declarations. * @@ -493,14 +499,18 @@ export interface ComponentDecorator { * * See the module-level docstring for a full list of known limitations. */ -export function locateComponentDecorators(code: string): ComponentDecorator[] { +export function locateComponentDecorators( + code: string, + decoratorNames: Iterable = DEFAULT_DECORATOR_NAMES, +): ComponentDecorator[] { + const names = [...new Set(decoratorNames)].filter((name) => name.length > 0) // Pass 1: find every `@Component(...)` and bound its args list. The walk // skips comments and string/template literals, so a commented-out or // quoted `@Component(` is not mistaken for a real decorator. That matters // beyond ignoring it: a phantom occurrence between a real decorator and // its class would bound the real one's class-name scan in pass 2, leaving // the class paired with the phantom's metadata. - type Found = { decoratorStart: number; openParen: number; closeParen: number } + type Found = { decoratorStart: number; openParen: number; closeParen: number; decorator: string } const found: Found[] = [] let i = 0 while (i < code.length) { @@ -515,16 +525,12 @@ export function locateComponentDecorators(code: string): ComponentDecorator[] { i = close === -1 ? code.length : close + 1 continue } - if (ch === '@' && code.startsWith(DECORATOR_NAME, i + 1)) { - let j = i + 1 + DECORATOR_NAME.length - while (j < code.length && WS_RE.test(code[j])) j++ - if (code[j] === '(') { - const closeParen = findClosingDelim(code, j) - if (closeParen !== -1) { - found.push({ decoratorStart: i, openParen: j, closeParen }) - i = closeParen + 1 - continue - } + if (ch === '@') { + const match = matchDecoratorCall(code, i + 1, names) + if (match) { + found.push({ decoratorStart: i, ...match }) + i = match.closeParen + 1 + continue } } i++ @@ -535,16 +541,38 @@ export function locateComponentDecorators(code: string): ComponentDecorator[] { // decorator's class-name scan from claiming a sibling's class. const out: ComponentDecorator[] = [] for (let i = 0; i < found.length; i++) { - const { openParen, closeParen } = found[i] + const { openParen, closeParen, decorator } = found[i] const scanEnd = i + 1 < found.length ? found[i + 1].decoratorStart : code.length const className = findClassName(code, closeParen + 1, scanEnd) if (className !== null) { - out.push({ argsRange: [openParen, closeParen], className }) + out.push({ argsRange: [openParen, closeParen], className, decorator }) } } return out } +/** + * If one of `names` followed by optional whitespace and a balanced `(...)` + * starts at `start` (just past an `@`), return which name and the `(` / `)` + * offsets. A name only matches whole: `Cmp` is not found in `@CmpX(` or + * `@Cmp.x(`, since what follows it must be `(`. + */ +function matchDecoratorCall( + code: string, + start: number, + names: readonly string[], +): { decorator: string; openParen: number; closeParen: number } | null { + for (const name of names) { + if (!code.startsWith(name, start)) continue + let j = start + name.length + while (j < code.length && WS_RE.test(code[j])) j++ + if (code[j] !== '(') continue + const closeParen = findClosingDelim(code, j) + if (closeParen !== -1) return { decorator: name, openParen: j, closeParen } + } + return null +} + /** * Find the first `class IDENT` whose `class` keyword appears in * `[start, end)`. Returns the identifier (Unicode-aware), or null if no From b924c88cbb32998813fe0d48e1fbd39586c18e22 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 13:42:36 +0800 Subject: [PATCH 11/12] fix(compiler): recognise constructor parameter decorators by their @angular/core import, like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@Inject`, `@Optional`, `@Self`, `@SkipSelf`, `@Host` and `@Attribute` on constructor parameters were matched by their written name. So with `import { Inject as Inj } from '@angular/core'`, `@Inj(TOKEN)` was ignored and then removed: the factory injected the parameter's type instead of `TOKEN`, and `setClassMetadata` / JIT `ctorParameters` dropped it. `@ng.Inject(TOKEN)` was ignored by the factory the same way. And another module's `@Inject` / `@Optional` was taken as Angular's. Like ngtsc (`isAngularCore` in `getConstructorDependencies`, and the JIT `downlevel_decorators_transform`), a parameter decorator is now Angular's only when it's imported from `@angular/core`: by name under any alias, or through a namespace import. It's the same check as the class and member decorators (`angular_core_decorator`), used for: - the factory (`ɵfac`) of components, directives, pipes, injectables and NgModules - `setClassMetadata`'s `ctorParameters`, which lists Angular's decorators as written (`type: Inj`), and gives a parameter whose decorators are all another module's `decorators: []`, like ngtsc - JIT `ctorParameters` - removing the decorators, and dropping the imports only they use Another module's or a local parameter decorator is left on the parameter (AOT), or lowered as `__param(index, decorator)` in the class's `__decorate` (JIT), as TypeScript does for ngtsc. Its import stays. Checked against @angular/compiler-cli 22.1.7 and its JIT transform (33 probes in tests/fixtures/ctor_param_decorators_ngtsc.json, 29 compared): named, aliased, namespaced, another module's, local, another module's namespace, mixed and swapped (`import {Optional as Self}`) imports, on `@Component`, `@Directive`, `@Injectable`, `@Pipe` and `@NgModule`. Skipped: an aliased `@Injectable` (ngtsc gives it no factory), and `@NgModule` with `@Inject(TOKEN)`, whose factory ignores `@Inject` on main too. The public `extract_*_metadata` and `build_ctor_params_metadata`, which don't get the file's imports, still match by name (`build_ctor_params_metadata_in` takes them). Nine unit-test sources that used `@Inject` / `@Optional` / ... without importing them now import them from `@angular/core`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/class_metadata/builders.rs | 51 +- .../src/class_metadata/mod.rs | 4 +- .../src/component/decorator.rs | 148 ++-- .../src/component/import_elision.rs | 26 +- .../src/component/transform.rs | 138 ++-- .../src/directive/decorator.rs | 45 +- .../oxc_angular_compiler/src/directive/mod.rs | 7 +- .../src/directive/property_decorators.rs | 18 + .../src/injectable/decorator.rs | 36 +- crates/oxc_angular_compiler/src/lib.rs | 2 +- .../src/ng_module/decorator.rs | 34 +- .../src/pipe/decorator.rs | 34 +- .../ctor_param_decorator_identity_test.rs | 262 +++++++ .../fixtures/ctor_param_decorators_ngtsc.json | 681 ++++++++++++++++++ napi/angular-compiler/src/lib.rs | 3 +- 15 files changed, 1279 insertions(+), 210 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/ctor_param_decorator_identity_test.rs create mode 100644 crates/oxc_angular_compiler/tests/fixtures/ctor_param_decorators_ngtsc.json diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index 21c5226ad..1b13695b5 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -317,6 +317,9 @@ fn build_template_entry<'a>(allocator: &'a Allocator, content: &'a str) -> Liter /// using the constructor dependency metadata and namespace registry. This matches /// Angular's behavior where type-only imports need namespace imports because /// TypeScript types are erased at runtime. +/// +/// Without the file's imports, a parameter decorator is Angular's by its name; +/// [`build_ctor_params_metadata_in`] recognises only Angular's, like ngtsc. pub fn build_ctor_params_metadata<'a>( allocator: &'a Allocator, class: &Class<'a>, @@ -324,6 +327,34 @@ pub fn build_ctor_params_metadata<'a>( namespace_registry: &mut NamespaceRegistry<'a>, import_map: &ImportMap<'a>, source_text: Option<&'a str>, +) -> Option> { + build_ctor_params_metadata_in( + allocator, + class, + constructor_deps, + namespace_registry, + import_map, + source_text, + None, + ) +} + +/// [`build_ctor_params_metadata`] for a class in the file `consts` was +/// collected from. +/// +/// A parameter decorator (`@Inject()`, `@Optional()`, ...) is listed only when +/// it's Angular's, imported from `@angular/core` by name, under any alias, or +/// through a namespace import (see [`crate::directive::angular_param_decorator`]). +/// Like ngtsc, a parameter that has decorators, none of them Angular's, gets +/// `decorators: []`. +pub fn build_ctor_params_metadata_in<'a>( + allocator: &'a Allocator, + class: &Class<'a>, + constructor_deps: Option<&[R3DependencyMetadata<'a>]>, + namespace_registry: &mut NamespaceRegistry<'a>, + import_map: &ImportMap<'a>, + source_text: Option<&'a str>, + consts: Option<&StringConsts<'a>>, ) -> Option> { // Find constructor let constructor = class.body.body.iter().find_map(|element| { @@ -358,9 +389,11 @@ pub fn build_ctor_params_metadata<'a>( map_entries.push(LiteralMapEntry::new(Ident::from("type"), type_expr, false)); - // Extract decorators from the parameter - let param_decorators = extract_angular_decorators_from_param(param); - if !param_decorators.is_empty() { + // Extract decorators from the parameter. ngtsc lists the Angular ones + // whenever the parameter has decorators at all (`decorators: []` when + // none of them is Angular's). + let param_decorators = extract_angular_decorators_from_param(param, consts); + if !param.decorators.is_empty() { let decorators_array = build_decorator_metadata_array( &allocator, ¶m_decorators, @@ -894,20 +927,16 @@ fn extract_param_type_expression<'a>( } } -/// Extract Angular decorators from a constructor parameter. +/// Extract Angular's decorators from a constructor parameter (see +/// [`crate::directive::angular_param_decorator`]). fn extract_angular_decorators_from_param<'a, 'b>( param: &'b FormalParameter<'a>, + consts: Option<&StringConsts<'a>>, ) -> std::vec::Vec<&'b Decorator<'a>> { - const ANGULAR_PARAM_DECORATORS: &[&str] = - &["Inject", "Optional", "Self", "SkipSelf", "Host", "Attribute"]; - param .decorators .iter() - .filter(|d| { - let name = get_decorator_name(d); - name.is_some_and(|n| ANGULAR_PARAM_DECORATORS.contains(&n)) - }) + .filter(|d| crate::directive::angular_param_decorator(d, consts).is_some()) .collect() } diff --git a/crates/oxc_angular_compiler/src/class_metadata/mod.rs b/crates/oxc_angular_compiler/src/class_metadata/mod.rs index d0339fd5b..60dca9852 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/mod.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/mod.rs @@ -15,8 +15,8 @@ mod compiler; mod metadata; pub use builders::{ - build_ctor_params_metadata, build_decorator_metadata_array, build_prop_decorators_metadata, - build_prop_decorators_metadata_in, + build_ctor_params_metadata, build_ctor_params_metadata_in, build_decorator_metadata_array, + build_prop_decorators_metadata, build_prop_decorators_metadata_in, }; pub use compiler::{ compile_class_metadata, compile_component_class_metadata, diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 054f07c69..25536d220 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -254,7 +254,7 @@ pub fn extract_component_metadata<'a>( // Extract constructor dependencies for factory generation // This enables proper DI for component constructors metadata.constructor_deps = - extract_constructor_deps(allocator, class, import_map, has_superclass); + extract_constructor_deps(allocator, class, import_map, has_superclass, consts); // Inputs/outputs from the `inputs:`/`outputs:` metadata, overridden by // @Input/@Output/signal members ({...fromMeta, ...fromFields} in ngtsc). @@ -866,6 +866,7 @@ fn extract_constructor_deps<'a>( class: &'a Class<'a>, import_map: &ImportMap<'a>, has_superclass: bool, + consts: &StringConsts<'a>, ) -> Option>> { // Find the constructor method let constructor = class.body.body.iter().find_map(|element| { @@ -884,7 +885,7 @@ fn extract_constructor_deps<'a>( let params = &ctor.value.params; for param in ¶ms.items { - let dep = extract_param_dependency(param, import_map); + let dep = extract_param_dependency(param, import_map, consts); deps.push(dep); } @@ -904,9 +905,12 @@ fn extract_constructor_deps<'a>( /// /// The `import_map` is used to look up the source module of the token, /// enabling proper tracking of import origins for constructor dependencies. +/// Only Angular's parameter decorators count, imported from `@angular/core` +/// (see [`crate::directive::angular_param_decorator`]). fn extract_param_dependency<'a>( param: &'a oxc_ast::ast::FormalParameter<'a>, import_map: &ImportMap<'a>, + consts: &StringConsts<'a>, ) -> R3DependencyMetadata<'a> { // Extract flags and @Inject token from decorators let mut optional = false; @@ -917,8 +921,8 @@ fn extract_param_dependency<'a>( let mut attribute_name: Option> = None; for decorator in ¶m.decorators { - if let Some(name) = get_decorator_name(&decorator.expression) { - match name.as_str() { + if let Some(name) = crate::directive::angular_param_decorator(decorator, Some(consts)) { + match name { "Inject" => { // @Inject(TOKEN) - extract the token if let Expression::CallExpression(call) = &decorator.expression { @@ -992,23 +996,6 @@ fn extract_param_dependency<'a>( dep } -/// Get the name of a decorator from its expression. -fn get_decorator_name<'a>(expr: &'a Expression<'a>) -> Option> { - match expr { - // @Optional - Expression::Identifier(id) => Some(id.name.clone().into()), - // @Optional() - Expression::CallExpression(call) => { - if let Expression::Identifier(id) = &call.callee { - Some(id.name.clone().into()) - } else { - None - } - } - _ => None, - } -} - /// Extract the injection token from an @Inject decorator argument. fn extract_inject_token<'a>(arg: &'a Argument<'a>) -> Option> { match arg { @@ -1053,30 +1040,52 @@ fn extract_param_token<'a>(param: &'a oxc_ast::ast::FormalParameter<'a>) -> Opti // Decorator Span Collection for Removal // ============================================================================= -/// Collect all decorator spans from constructor parameters. +/// Collect the spans of Angular's constructor parameter decorators. /// /// Parameter decorators like `@Optional()`, `@Inject()`, `@Host()`, `@Self()`, /// `@SkipSelf()`, and `@Attribute()` need to be removed from the output since /// their metadata is extracted into the factory function's inject flags. +/// Like ngtsc, only Angular's own count (imported from `@angular/core`, under +/// any alias or through a namespace import, see +/// [`crate::directive::angular_param_decorator`]); another module's is left +/// in place. /// /// These spans are used by `transform.rs` to remove the decorators from the /// source text during transformation. -pub fn collect_constructor_decorator_spans(class: &Class<'_>, spans: &mut std::vec::Vec) { - // Find the constructor method - for element in &class.body.body { - if let ClassElement::MethodDefinition(method) = element { - if method.kind == MethodDefinitionKind::Constructor { - // Iterate over constructor parameters - for param in &method.value.params.items { - // Collect all decorator spans from this parameter - for decorator in ¶m.decorators { - spans.push(decorator.span); - } - } - // Only one constructor per class - break; - } +pub fn collect_constructor_decorator_spans( + class: &Class<'_>, + consts: &StringConsts<'_>, + spans: &mut std::vec::Vec, +) { + for_each_constructor_param_decorator(class, |decorator| { + if crate::directive::angular_param_decorator(decorator, Some(consts)).is_some() { + spans.push(decorator.span); } + }); +} + +/// Collect the spans of every constructor parameter decorator, Angular's or +/// not. JIT output removes them all: Angular's go into `ctorParameters`, the +/// rest into the class's `__decorate` call as `__param(...)`. +pub fn collect_all_constructor_decorator_spans(class: &Class<'_>, spans: &mut std::vec::Vec) { + for_each_constructor_param_decorator(class, |decorator| spans.push(decorator.span)); +} + +/// Call `f` with each decorator on the constructor's parameters, in order. +fn for_each_constructor_param_decorator<'a>( + class: &'a Class<'a>, + mut f: impl FnMut(&'a Decorator<'a>), +) { + let constructor = class.body.body.iter().find_map(|element| match element { + ClassElement::MethodDefinition(method) + if method.kind == MethodDefinitionKind::Constructor => + { + Some(method) + } + _ => None, + }); + for param in constructor.into_iter().flat_map(|ctor| &ctor.value.params.items) { + param.decorators.iter().for_each(&mut f); } } @@ -2661,7 +2670,7 @@ mod tests { #[test] fn test_component_with_inject_decorator() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Inject} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2692,7 +2701,7 @@ mod tests { #[test] fn test_component_with_optional_decorator() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Optional} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2722,7 +2731,7 @@ mod tests { // Angular's reference compiler filters out `null` literal type nodes from // the union; when exactly one type remains it becomes the token. let code = r#" - import {Component} from '@angular/core'; + import {Component, Optional} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2850,7 +2859,7 @@ mod tests { #[test] fn test_component_with_skip_self_decorator() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Optional, SkipSelf} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2875,7 +2884,7 @@ mod tests { #[test] fn test_component_with_self_and_host_decorators() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Self, Host} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2904,7 +2913,7 @@ mod tests { #[test] fn test_component_with_combined_decorators() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Optional, Inject} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -2928,7 +2937,7 @@ mod tests { #[test] fn test_component_with_attribute_decorator() { let code = r#" - import {Component} from '@angular/core'; + import {Component, Attribute} from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3157,7 +3166,7 @@ mod tests { fn test_constructor_dep_token_source_module_with_inject_decorator() { // Test that @Inject token source module is tracked from import let code = r#" - import {Component} from '@angular/core'; + import {Component, Inject} from '@angular/core'; import { WINDOW } from "@bitwarden/common"; @Component({ @@ -3303,9 +3312,22 @@ mod tests { collect_member_decorator_spans(class, &consts, spans); } + /// [`collect_constructor_decorator_spans`] for `class`, declared in `code`. + fn collect_constructor_decorators( + code: &str, + class: &Class<'_>, + spans: &mut std::vec::Vec, + ) { + let allocator = Allocator::default(); + let parser_ret = Parser::new(&allocator, code, SourceType::tsx()).parse(); + let consts = crate::directive::collect_string_consts(&allocator, &parser_ret.program); + collect_constructor_decorator_spans(class, &consts, spans); + } + #[test] fn test_collect_constructor_decorator_spans() { let code = r#" + import { Component, Optional, Inject, Host, Self } from '@angular/core'; @Component({ selector: 'app-test', template: '' @@ -3321,13 +3343,43 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_constructor_decorator_spans(class, &mut spans); + collect_constructor_decorators(code, class, &mut spans); // Should collect 4 decorators: @Optional, @Inject, @Host, @Self assert_eq!(spans.len(), 4); }); } + /// Like ngtsc, only Angular's parameter decorators are removed: aliased or + /// namespaced ones are, another module's (even named `Inject`) and a + /// local one aren't. + #[test] + fn test_collect_constructor_decorator_spans_by_import() { + let code = r#" + import * as ng from '@angular/core'; + import { Component, Inject as Inj } from '@angular/core'; + import { Inject, Optional } from './other'; + function Self(): ParameterDecorator { return () => {}; } + @Component({ selector: 'app-test', template: '' }) + class TestComponent { + constructor( + @Inj(TOKEN) a: A, + @ng.Optional() b: B, + @Inject(TOKEN) c: C, + @Optional() d: D, + @Self() e: E, + ) {} + } + "#; + with_first_class(code, |class| { + let mut spans = std::vec::Vec::new(); + collect_constructor_decorators(code, class, &mut spans); + let removed: std::vec::Vec<&str> = + spans.iter().map(|span| &code[span.start as usize..span.end as usize]).collect(); + assert_eq!(removed, ["@Inj(TOKEN)", "@ng.Optional()"]); + }); + } + #[test] fn test_collect_constructor_decorator_spans_empty() { let code = r#" @@ -3341,7 +3393,7 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_constructor_decorator_spans(class, &mut spans); + collect_constructor_decorators(code, class, &mut spans); // No parameter decorators assert_eq!(spans.len(), 0); @@ -3359,7 +3411,7 @@ mod tests { "#; with_first_class(code, |class| { let mut spans = std::vec::Vec::new(); - collect_constructor_decorator_spans(class, &mut spans); + collect_constructor_decorators(code, class, &mut spans); // No constructor assert_eq!(spans.len(), 0); diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index 3613e089a..7c91bcf07 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -71,7 +71,9 @@ impl<'a> ImportElisionAnalyzer<'a> { // First, collect all symbols that are used ONLY in constructor parameter decorators. // These should be elided because Angular removes these decorators during compilation. - let ctor_param_decorator_only = Self::collect_ctor_param_decorator_only_imports(program); + let consts = crate::directive::StringConsts::declarations_of(program); + let ctor_param_decorator_only = + Self::collect_ctor_param_decorator_only_imports(program, &consts); // Analyze each import declaration for stmt in &program.body { @@ -326,7 +328,10 @@ impl<'a> ImportElisionAnalyzer<'a> { /// TypeScript, so their decorator arguments have no runtime value references /// /// Reference: packages/compiler-cli/src/ngtsc/transform/jit/src/downlevel_decorators_transform.ts - fn collect_ctor_param_decorator_only_imports(program: &'a Program<'a>) -> FxHashSet<&'a str> { + fn collect_ctor_param_decorator_only_imports( + program: &'a Program<'a>, + consts: &crate::directive::StringConsts<'a>, + ) -> FxHashSet<&'a str> { let mut result = FxHashSet::default(); // Track: @@ -342,6 +347,7 @@ impl<'a> ImportElisionAnalyzer<'a> { for stmt in &program.body { Self::collect_uses_from_statement( stmt, + consts, &mut ctor_param_decorator_uses, &mut inject_arg_uses, &mut declare_prop_decorator_uses, @@ -373,6 +379,7 @@ impl<'a> ImportElisionAnalyzer<'a> { /// Recursively collect uses from a statement. fn collect_uses_from_statement( stmt: &'a Statement<'a>, + consts: &crate::directive::StringConsts<'a>, ctor_param_decorator_uses: &mut FxHashSet<&'a str>, inject_arg_uses: &mut FxHashSet<&'a str>, declare_prop_decorator_uses: &mut FxHashSet<&'a str>, @@ -382,6 +389,7 @@ impl<'a> ImportElisionAnalyzer<'a> { Statement::ClassDeclaration(class) => { Self::collect_uses_from_class( class, + consts, ctor_param_decorator_uses, inject_arg_uses, declare_prop_decorator_uses, @@ -394,6 +402,7 @@ impl<'a> ImportElisionAnalyzer<'a> { { Self::collect_uses_from_class( class, + consts, ctor_param_decorator_uses, inject_arg_uses, declare_prop_decorator_uses, @@ -405,6 +414,7 @@ impl<'a> ImportElisionAnalyzer<'a> { if let oxc_ast::ast::Declaration::ClassDeclaration(class) = &export.declaration { Self::collect_uses_from_class( class, + consts, ctor_param_decorator_uses, inject_arg_uses, declare_prop_decorator_uses, @@ -431,6 +441,7 @@ impl<'a> ImportElisionAnalyzer<'a> { /// Collect uses from a class declaration. fn collect_uses_from_class( class: &'a oxc_ast::ast::Class<'a>, + consts: &crate::directive::StringConsts<'a>, ctor_param_decorator_uses: &mut FxHashSet<&'a str>, inject_arg_uses: &mut FxHashSet<&'a str>, declare_prop_decorator_uses: &mut FxHashSet<&'a str>, @@ -454,6 +465,7 @@ impl<'a> ImportElisionAnalyzer<'a> { // This is the constructor - process parameter decorators specially Self::collect_uses_from_constructor_params( &method.value.params, + consts, ctor_param_decorator_uses, inject_arg_uses, ); @@ -499,6 +511,7 @@ impl<'a> ImportElisionAnalyzer<'a> { /// This is the key function that identifies parameter decorators and their arguments. fn collect_uses_from_constructor_params( params: &'a oxc_ast::ast::FormalParameters<'a>, + consts: &crate::directive::StringConsts<'a>, ctor_param_decorator_uses: &mut FxHashSet<&'a str>, inject_arg_uses: &mut FxHashSet<&'a str>, ) { @@ -520,8 +533,13 @@ impl<'a> ImportElisionAnalyzer<'a> { }; if let Some(name) = decorator_name { - // Check if this is a known parameter decorator - if PARAM_DECORATORS.contains(&name) { + // Check if this is one of Angular's parameter decorators, + // which compilation removes. Another module's `@Inject` + // stays in the output (like ngtsc), so its import must too. + if PARAM_DECORATORS.contains(&name) + && crate::directive::angular_param_decorator(decorator, Some(consts)) + .is_some() + { ctor_param_decorator_uses.insert(name); // If this is @Inject(TOKEN), collect the TOKEN argument diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 009168b8b..6bbde6ae9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -34,7 +34,7 @@ use super::namespace_registry::NamespaceRegistry; use crate::ast::expression::{BindingType, ParsedEventType}; use crate::ast::r3::{R3BoundAttribute, R3BoundEvent, SecurityContext}; use crate::class_metadata::{ - R3ClassMetadata, R3DeferPerComponentDependency, build_ctor_params_metadata, + R3ClassMetadata, R3DeferPerComponentDependency, build_ctor_params_metadata_in, build_decorator_metadata_array, build_prop_decorators_metadata_in, compile_class_metadata, compile_component_class_metadata, }; @@ -788,13 +788,14 @@ fn build_set_class_metadata_decls<'a>( None, Some(string_consts), ), - ctor_parameters: build_ctor_params_metadata( + ctor_parameters: build_ctor_params_metadata_in( &allocator, class, None, namespace_registry, import_map, Some(source), + Some(string_consts), ), prop_decorators: build_prop_decorators_metadata_in( &allocator, @@ -853,6 +854,10 @@ struct JitClassInfo { member_decorators: std::vec::Vec, /// All class-level decorator expression texts for __decorate call, in source order. all_class_decorator_texts: std::vec::Vec, + /// Constructor parameter decorators that aren't Angular's, as + /// `__param(index, decorator)` texts for the class's __decorate call, after + /// the class decorators (TypeScript's order). + param_decorator_texts: std::vec::Vec, /// Non-Angular member decorators that need __decorate() calls. non_angular_member_decorators: std::vec::Vec, } @@ -1055,10 +1060,19 @@ fn find_angular_decorator<'a>( } /// Extract constructor parameter info for JIT ctorParameters generation. +/// +/// Like Angular's JIT transform (`downlevel_decorators_transform.ts`), a +/// parameter decorator goes into `ctorParameters` only when it's Angular's, +/// imported from `@angular/core` (see +/// [`crate::directive::angular_param_decorator`]), listed as written +/// (`{ type: Inj }`, `{ type: ng.Optional }`). Any other one stays a decorator +/// of the class, lowered as `__param(index, decorator)`; those are returned +/// second, in source order. fn extract_jit_ctor_params( source: &str, class: &oxc_ast::ast::Class<'_>, -) -> std::vec::Vec { + consts: &crate::directive::StringConsts<'_>, +) -> (std::vec::Vec, std::vec::Vec) { use oxc_ast::ast::{ClassElement, MethodDefinitionKind}; let constructor = class.body.body.iter().find_map(|element| { @@ -1071,56 +1085,50 @@ fn extract_jit_ctor_params( }); let Some(ctor) = constructor else { - return std::vec::Vec::new(); + return (std::vec::Vec::new(), std::vec::Vec::new()); }; let mut params = std::vec::Vec::new(); - for param in &ctor.value.params.items { + let mut other_decorators = std::vec::Vec::new(); + for (index, param) in ctor.value.params.items.iter().enumerate() { // Extract type name from type annotation (directly on FormalParameter) let type_name = param .type_annotation .as_ref() .and_then(|ann| extract_type_name_from_annotation(&ann.type_annotation)); - // Extract Angular decorators + // Angular's decorators go into ctorParameters, by their written name. let mut decorators = std::vec::Vec::new(); for decorator in ¶m.decorators { - if let Expression::CallExpression(call) = &decorator.expression { - let dec_name = match &call.callee { - Expression::Identifier(id) => Some(id.name.to_string()), - _ => None, - }; - if let Some(name) = dec_name { - match name.as_str() { - "Inject" | "Optional" | "SkipSelf" | "Self" | "Host" | "Attribute" => { - let args = if call.arguments.is_empty() { - None - } else { - // Extract args from source - let args_start = call.arguments.first().unwrap().span().start; - let args_end = call.arguments.last().unwrap().span().end; - Some(source[args_start as usize..args_end as usize].to_string()) - }; - decorators.push(JitParamDecorator { name, args }); - } - _ => {} - } - } - } else if let Expression::Identifier(id) = &decorator.expression { - let name = id.name.to_string(); - match name.as_str() { - "Optional" | "SkipSelf" | "Self" | "Host" => { - decorators.push(JitParamDecorator { name, args: None }); - } - _ => {} - } + if crate::directive::angular_param_decorator(decorator, Some(consts)).is_none() { + let expr = decorator.expression.span(); + other_decorators.push(format!( + "__param({index}, {})", + &source[expr.start as usize..expr.end as usize] + )); + continue; } + let (callee, args) = match &decorator.expression { + Expression::CallExpression(call) => { + let args = match (call.arguments.first(), call.arguments.last()) { + (Some(first), Some(last)) => Some( + source[first.span().start as usize..last.span().end as usize] + .to_string(), + ), + _ => None, + }; + (call.callee.span(), args) + } + expr => (expr.span(), None), + }; + let name = source[callee.start as usize..callee.end as usize].to_string(); + decorators.push(JitParamDecorator { name, args }); } params.push(JitCtorParam { type_name, decorators }); } - params + (params, other_decorators) } /// Angular field decorators that go into `static propDecorators`. @@ -2156,8 +2164,10 @@ fn transform_angular_file_jit( } } - // Extract constructor parameters for ctorParameters - let ctor_params = extract_jit_ctor_params(source, class); + // Extract constructor parameters for ctorParameters, and the other + // parameter decorators for the class's `__decorate`. + let (ctor_params, param_decorator_texts) = + extract_jit_ctor_params(source, class, &string_consts); // Extract Angular and non-Angular member decorators let (member_decorators, non_angular_member_decorators) = @@ -2175,6 +2185,7 @@ fn transform_angular_file_jit( ctor_params, member_decorators, all_class_decorator_texts, + param_decorator_texts, non_angular_member_decorators, }); @@ -2201,7 +2212,12 @@ fn transform_angular_file_jit( // when signal-API lowering synthesized decorators that need to resolve to // `i0.Input`/`i0.Output`/etc. at runtime). let mut additional_imports = String::new(); - additional_imports.push_str("import { __decorate } from \"tslib\";\n"); + // `__param` lowers a parameter decorator that isn't Angular's. + if jit_classes.iter().any(|c| !c.param_decorator_texts.is_empty()) { + additional_imports.push_str("import { __decorate, __param } from \"tslib\";\n"); + } else { + additional_imports.push_str("import { __decorate } from \"tslib\";\n"); + } if !core_namespace_imported && jit_classes_need_angular_core_namespace(&jit_classes, &core_namespace) { @@ -2282,7 +2298,7 @@ fn transform_angular_file_jit( // 4b. Remove ALL member decorators and constructor param decorators { let mut decorator_spans: std::vec::Vec = std::vec::Vec::new(); - super::decorator::collect_constructor_decorator_spans(class, &mut decorator_spans); + super::decorator::collect_all_constructor_decorator_spans(class, &mut decorator_spans); super::decorator::collect_all_member_decorator_spans(class, &mut decorator_spans); for span in &decorator_spans { let mut end = span.end as usize; @@ -2368,7 +2384,13 @@ fn transform_angular_file_jit( } // Emit class-level __decorate() with ALL class decorators - let all_decorator_text = jit_info.all_class_decorator_texts.join(",\n "); + let all_decorator_text = jit_info + .all_class_decorator_texts + .iter() + .chain(&jit_info.param_decorator_texts) + .map(String::as_str) + .collect::>() + .join(",\n "); after_class.push_str(&format!( "{} = __decorate([\n {}\n], {});\n", jit_info.class_name, all_decorator_text, jit_info.class_name @@ -2713,6 +2735,7 @@ pub fn transform_angular_file( // Collect constructor parameter decorators (@Optional, @Inject, etc.) collect_constructor_decorator_spans( class, + &string_consts, &mut decorator_spans_to_remove, ); // Collect member decorators (@Input, @Output, @HostBinding, etc.) @@ -2813,13 +2836,14 @@ pub fn transform_angular_file( Some(metadata.styles.as_slice()), Some(&string_consts), ), - ctor_parameters: build_ctor_params_metadata( + ctor_parameters: build_ctor_params_metadata_in( &allocator, class, ctor_deps_slice, &mut file_namespace_registry, &import_map, Some(source), + Some(&string_consts), ), prop_decorators: build_prop_decorators_metadata_in( &allocator, @@ -2979,7 +3003,11 @@ pub fn transform_angular_file( decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) - collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + collect_constructor_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Collect member decorators (@Input, @Output, @HostBinding, etc.) collect_member_decorator_spans( class, @@ -3121,7 +3149,11 @@ pub fn transform_angular_file( decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) - collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + collect_constructor_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Resolve namespace imports for pipe constructor deps if let Some(ref mut deps) = pipe_metadata.deps { @@ -3235,7 +3267,11 @@ pub fn transform_angular_file( decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) - collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + collect_constructor_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Resolve namespace imports for NgModule constructor deps if let Some(ref mut deps) = ng_module_metadata.deps { @@ -3391,7 +3427,11 @@ pub fn transform_angular_file( // user's constructor may still carry @Inject/@Optional/etc. // decorators that need to be stripped from the output (the // class metadata IIFE will pick them up). - collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + collect_constructor_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); @@ -3456,7 +3496,11 @@ pub fn transform_angular_file( decorator_spans_to_remove.push(span); } // Collect constructor parameter decorators (@Optional, @Inject, etc.) - collect_constructor_decorator_spans(class, &mut decorator_spans_to_remove); + collect_constructor_decorator_spans( + class, + &string_consts, + &mut decorator_spans_to_remove, + ); // Resolve namespace imports for constructor deps. if let Some(ref mut deps) = injectable_metadata.deps { diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index a0ff7b20c..8070cdff4 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -203,7 +203,8 @@ pub fn extract_directive_metadata<'a>( // Extract constructor dependencies for factory generation // This enables proper DI for directive constructors // See: packages/compiler-cli/src/ngtsc/annotations/common/src/di.ts - let constructor_deps = extract_constructor_deps(allocator, class, has_superclass, source_text); + let constructor_deps = + extract_constructor_deps(allocator, class, has_superclass, source_text, consts); if let Some(deps) = constructor_deps { builder = builder.deps(deps); } @@ -298,6 +299,7 @@ fn extract_constructor_deps<'a>( class: &'a Class<'a>, has_superclass: bool, source_text: Option<&'a str>, + consts: &StringConsts<'a>, ) -> Option>> { // Find the constructor method let constructor = class.body.body.iter().find_map(|element| { @@ -316,7 +318,7 @@ fn extract_constructor_deps<'a>( let mut deps = Vec::with_capacity_in(params.items.len(), &allocator); for param in ¶ms.items { - let dep = extract_param_dependency(allocator, param, source_text); + let dep = extract_param_dependency(allocator, param, source_text, consts); deps.push(dep); } @@ -332,11 +334,14 @@ fn extract_constructor_deps<'a>( } } -/// Extract dependency metadata from a single constructor parameter. +/// Extract dependency metadata from a single constructor parameter. Only +/// Angular's parameter decorators count, imported from `@angular/core` (see +/// [`super::angular_param_decorator`]). fn extract_param_dependency<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, source_text: Option<&'a str>, + consts: &StringConsts<'a>, ) -> R3DependencyMetadata<'a> { // Extract flags and @Inject token from decorators let mut optional = false; @@ -347,8 +352,8 @@ fn extract_param_dependency<'a>( let mut attribute_name: Option> = None; for decorator in ¶m.decorators { - if let Some(name) = get_decorator_name_from_expr(&decorator.expression) { - match name.as_str() { + if let Some(name) = super::angular_param_decorator(decorator, Some(consts)) { + match name { "Inject" => { // @Inject(TOKEN) - extract the token if let Expression::CallExpression(call) = &decorator.expression { @@ -410,23 +415,6 @@ fn extract_param_dependency<'a>( } } -/// Get the name of a decorator from its expression. -fn get_decorator_name_from_expr<'a>(expr: &'a Expression<'a>) -> Option> { - match expr { - // @Optional - Expression::Identifier(id) => Some(id.name.clone().into()), - // @Optional() - Expression::CallExpression(call) => { - if let Expression::Identifier(id) = &call.callee { - Some(id.name.clone().into()) - } else { - None - } - } - _ => None, - } -} - /// Extract the injection token from a parameter's type annotation. /// /// Returns a bare `ReadVar` expression with the type name. The caller @@ -514,6 +502,17 @@ impl<'a> StringConsts<'a> { self.strings.get(name) } + /// `program`'s declarations without its folded strings: enough to tell + /// where a name is imported from (see + /// [`super::angular_param_decorator`]). + pub(crate) fn declarations_of(program: &'a Program<'a>) -> Self { + Self { + strings: HashMap::default(), + program: Some(program), + scope: std::cell::OnceCell::new(), + } + } + /// The file's top-level declarations, for the partial evaluator. pub(crate) fn scope(&self) -> &FileScope<'a> { self.scope.get_or_init(|| self.program.map(FileScope::collect).unwrap_or_default()) @@ -2424,7 +2423,7 @@ mod tests { // Regression test for issue #285: // `@Optional() svc: MyService | null` must resolve the token to `MyService`. let code = r#" - import {Directive} from '@angular/core'; + import {Directive, Optional} from '@angular/core'; @Directive({ selector: '[myDir]' }) class MyDirective { constructor(@Optional() private svc: MyService | null) {} diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index f77a06626..becf394de 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -47,9 +47,10 @@ pub use metadata::{ R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; pub(crate) use property_decorators::{ - angular_class_decorator, angular_member_decorator, extract_host_bindings_in, - extract_host_listeners_in, parse_decorator_queries, try_parse_signal_input, - try_parse_signal_model, try_parse_signal_output, unwrap_initializer_api_expr, + angular_class_decorator, angular_member_decorator, angular_param_decorator, + extract_host_bindings_in, extract_host_listeners_in, parse_decorator_queries, + try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, + unwrap_initializer_api_expr, }; pub use property_decorators::{ extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 2ab293f65..9fad583d0 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -104,6 +104,24 @@ pub(crate) fn angular_member_decorator( angular_core_decorator(decorator, consts, MEMBER_DECORATORS) } +/// Angular's constructor parameter decorators, which are compiled into the +/// factory (`ɵfac`), `ctorParameters` and `setClassMetadata`. +const PARAM_DECORATORS: &[&str] = &["Inject", "Optional", "Self", "SkipSelf", "Host", "Attribute"]; + +/// Which of Angular's constructor parameter decorators ([`PARAM_DECORATORS`]) +/// `decorator` is (see [`angular_core_decorator`]), like ngtsc's +/// `isAngularCore` in `getConstructorDependencies` and the JIT +/// `ctorParameters` transform: `@Inj(TOKEN)` for `import { Inject as Inj }` +/// and `@core.Optional()` count, another module's or a local `@Inject` doesn't. +/// Without the file (the public `extract_*` functions), any decorator with +/// that name. +pub(crate) fn angular_param_decorator( + decorator: &Decorator<'_>, + consts: Option<&super::StringConsts<'_>>, +) -> Option<&'static str> { + angular_core_decorator(decorator, consts, PARAM_DECORATORS) +} + /// Which of Angular's class decorators ([`CLASS_DECORATORS`]) `decorator` is, /// in the file `consts` was collected from: one imported from `@angular/core` /// (see [`angular_core_decorator`]), like ngtsc's `findAngularDecorator`. diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 8247114dc..c20124ac7 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -235,7 +235,8 @@ pub fn find_injectable_decorator_span(class: &Class<'_>) -> Option { /// Extract injectable metadata from a class decorated with `@Injectable`. /// -/// Without the file's imports, any `@Injectable` identifier counts; +/// Without the file's imports, any `@Injectable` identifier counts, and so +/// does any constructor parameter decorator named `Inject`, `Optional`, ...; /// [`extract_injectable_metadata_in`] only counts Angular's, like the compiler. pub fn extract_injectable_metadata<'a>( allocator: &'a Allocator, @@ -276,7 +277,7 @@ pub(crate) fn extract_injectable_metadata_in<'a>( use_factory: None, use_value: None, use_existing: None, - deps: extract_constructor_deps(allocator, class, source_text), + deps: extract_constructor_deps(allocator, class, source_text, consts), }); } @@ -302,7 +303,7 @@ pub(crate) fn extract_injectable_metadata_in<'a>( let use_existing = extract_use_existing(allocator, config_obj, source_text); // Extract constructor dependencies - let deps = extract_constructor_deps(allocator, class, source_text); + let deps = extract_constructor_deps(allocator, class, source_text, consts); Some(InjectableMetadata { class_name, @@ -551,6 +552,7 @@ pub fn extract_constructor_deps<'a>( allocator: &'a Allocator, class: &'a Class<'a>, source_text: Option<&'a str>, + consts: Option<&StringConsts<'_>>, ) -> Option>> { // Find the constructor method let constructor = class.body.body.iter().find_map(|element| { @@ -567,18 +569,21 @@ pub fn extract_constructor_deps<'a>( let mut deps = Vec::with_capacity_in(params.items.len(), &allocator); for param in ¶ms.items { - let dep = extract_param_dependency(allocator, param, source_text); + let dep = extract_param_dependency(allocator, param, source_text, consts); deps.push(dep); } Some(deps) } -/// Extract dependency metadata from a single constructor parameter. +/// Extract dependency metadata from a single constructor parameter. With the +/// file's `consts`, only Angular's parameter decorators count, imported from +/// `@angular/core` (see [`crate::directive::angular_param_decorator`]). fn extract_param_dependency<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, source_text: Option<&'a str>, + consts: Option<&StringConsts<'_>>, ) -> R3DependencyMetadata<'a> { // Extract flags and @Inject token from decorators let mut optional = false; @@ -589,8 +594,8 @@ fn extract_param_dependency<'a>( let mut attribute_name: Option> = None; for decorator in ¶m.decorators { - if let Some(name) = get_decorator_name(&decorator.expression) { - match name.as_str() { + if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { + match name { "Inject" => { // @Inject(TOKEN) - extract the token if let Expression::CallExpression(call) = &decorator.expression { @@ -652,23 +657,6 @@ fn extract_param_dependency<'a>( } } -/// Get the name of a decorator from its expression. -fn get_decorator_name<'a>(expr: &'a Expression<'a>) -> Option> { - match expr { - // @Optional - Expression::Identifier(id) => Some(id.name.clone().into()), - // @Optional() - Expression::CallExpression(call) => { - if let Expression::Identifier(id) = &call.callee { - Some(id.name.clone().into()) - } else { - None - } - } - _ => None, - } -} - /// Extract the injection token from a parameter's type annotation. fn extract_param_token<'a>( allocator: &'a Allocator, diff --git a/crates/oxc_angular_compiler/src/lib.rs b/crates/oxc_angular_compiler/src/lib.rs index b95354cd4..e8b13c627 100644 --- a/crates/oxc_angular_compiler/src/lib.rs +++ b/crates/oxc_angular_compiler/src/lib.rs @@ -155,7 +155,7 @@ pub use class_debug_info::{R3ClassDebugInfo, compile_class_debug_info}; // Re-export class metadata types pub use class_metadata::{ R3ClassMetadata, R3DeferPerComponentDependency, build_ctor_params_metadata, - build_decorator_metadata_array, build_prop_decorators_metadata, + build_ctor_params_metadata_in, build_decorator_metadata_array, build_prop_decorators_metadata, build_prop_decorators_metadata_in, compile_class_metadata, compile_component_class_metadata, compile_component_metadata_async_resolver, compile_opaque_async_class_metadata, }; diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index 323527ba8..2db813dfb 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -180,7 +180,8 @@ impl<'a> NgModuleMetadata<'a> { /// export class AppModule {} /// ``` /// -/// Without the file's imports, any decorator named `NgModule` counts; +/// Without the file's imports, any decorator named `NgModule` counts, and so +/// does any constructor parameter decorator named `Optional`, `Self`, ...; /// [`extract_ng_module_metadata_in`] only counts Angular's, like the compiler. pub fn extract_ng_module_metadata<'a>( allocator: &'a Allocator, @@ -285,7 +286,7 @@ pub(crate) fn extract_ng_module_metadata_in<'a>( } // Extract constructor dependencies - metadata.deps = extract_constructor_deps(allocator, class); + metadata.deps = extract_constructor_deps(allocator, class, consts); Some(metadata) } @@ -436,6 +437,7 @@ fn extract_identifier_array<'a>( pub fn extract_constructor_deps<'a>( allocator: &'a Allocator, class: &'a Class<'a>, + consts: Option<&StringConsts<'_>>, ) -> Option>> { // Find the constructor method let constructor = class.body.body.iter().find_map(|element| { @@ -452,17 +454,20 @@ pub fn extract_constructor_deps<'a>( let mut deps = Vec::with_capacity_in(params.items.len(), &allocator); for param in ¶ms.items { - let dep = extract_param_dependency(allocator, param); + let dep = extract_param_dependency(allocator, param, consts); deps.push(dep); } Some(deps) } -/// Extract dependency metadata from a single constructor parameter. +/// Extract dependency metadata from a single constructor parameter. With the +/// file's `consts`, only Angular's parameter decorators count, imported from +/// `@angular/core` (see [`crate::directive::angular_param_decorator`]). fn extract_param_dependency<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, + consts: Option<&StringConsts<'_>>, ) -> R3DependencyMetadata<'a> { // Extract flags from decorators let mut optional = false; @@ -472,8 +477,8 @@ fn extract_param_dependency<'a>( let mut attribute_name: Option> = None; for decorator in ¶m.decorators { - if let Some(name) = get_decorator_name(&decorator.expression) { - match name.as_str() { + if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { + match name { "Optional" => optional = true, "SkipSelf" => skip_self = true, "Self" => self_ = true, @@ -524,23 +529,6 @@ fn extract_param_dependency<'a>( } } -/// Get the name of a decorator from its expression. -fn get_decorator_name<'a>(expr: &'a Expression<'a>) -> Option> { - match expr { - // @Optional - Expression::Identifier(id) => Some(id.name.clone().into()), - // @Optional() - Expression::CallExpression(call) => { - if let Expression::Identifier(id) = &call.callee { - Some(id.name.clone().into()) - } else { - None - } - } - _ => None, - } -} - /// Extract the injection token from a parameter's type annotation. fn extract_param_token<'a>( allocator: &'a Allocator, diff --git a/crates/oxc_angular_compiler/src/pipe/decorator.rs b/crates/oxc_angular_compiler/src/pipe/decorator.rs index 804e62654..21f229bff 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -107,7 +107,8 @@ impl<'a> PipeMetadata<'a> { /// export class MyPipe implements PipeTransform {} /// ``` /// -/// Without the file's imports, any decorator named `Pipe` counts: +/// Without the file's imports, any decorator named `Pipe` counts, and so does +/// any constructor parameter decorator named `Inject`, `Optional`, ...: /// [`extract_pipe_metadata_in`] takes the file's [`StringConsts`] and only /// counts Angular's (imported from `@angular/core`), like the compiler. pub fn extract_pipe_metadata<'a>( @@ -181,7 +182,7 @@ pub fn extract_pipe_metadata_in<'a>( } // Extract constructor dependencies for factory generation - metadata.deps = extract_constructor_deps(allocator, class); + metadata.deps = extract_constructor_deps(allocator, class, consts); Some(metadata) } @@ -260,6 +261,7 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { fn extract_constructor_deps<'a>( allocator: &'a Allocator, class: &'a Class<'a>, + consts: Option<&StringConsts<'_>>, ) -> Option>> { // Find the constructor method let constructor = class.body.body.iter().find_map(|element| { @@ -276,17 +278,20 @@ fn extract_constructor_deps<'a>( let mut deps = Vec::with_capacity_in(params.items.len(), &allocator); for param in ¶ms.items { - let dep = extract_param_dependency(allocator, param); + let dep = extract_param_dependency(allocator, param, consts); deps.push(dep); } Some(deps) } -/// Extract dependency metadata from a single constructor parameter. +/// Extract dependency metadata from a single constructor parameter. With the +/// file's `consts`, only Angular's parameter decorators count, imported from +/// `@angular/core` (see [`crate::directive::angular_param_decorator`]). fn extract_param_dependency<'a>( allocator: &'a Allocator, param: &oxc_ast::ast::FormalParameter<'a>, + consts: Option<&StringConsts<'_>>, ) -> R3DependencyMetadata<'a> { // Extract flags and @Inject token from decorators let mut optional = false; @@ -297,8 +302,8 @@ fn extract_param_dependency<'a>( let mut attribute_name: Option> = None; for decorator in ¶m.decorators { - if let Some(name) = get_decorator_name(&decorator.expression) { - match name.as_str() { + if let Some(name) = crate::directive::angular_param_decorator(decorator, consts) { + match name { "Inject" => { // @Inject(TOKEN) - extract the token if let Expression::CallExpression(call) = &decorator.expression { @@ -359,23 +364,6 @@ fn extract_param_dependency<'a>( } } -/// Get the name of a decorator from its expression. -fn get_decorator_name<'a>(expr: &'a Expression<'a>) -> Option> { - match expr { - // @Optional - Expression::Identifier(id) => Some(id.name.clone().into()), - // @Optional() - Expression::CallExpression(call) => { - if let Expression::Identifier(id) = &call.callee { - Some(id.name.clone().into()) - } else { - None - } - } - _ => None, - } -} - /// Extract the injection token from a parameter's type annotation. fn extract_param_token<'a>( allocator: &'a Allocator, diff --git a/crates/oxc_angular_compiler/tests/ctor_param_decorator_identity_test.rs b/crates/oxc_angular_compiler/tests/ctor_param_decorator_identity_test.rs new file mode 100644 index 000000000..0ff1c11ec --- /dev/null +++ b/crates/oxc_angular_compiler/tests/ctor_param_decorator_identity_test.rs @@ -0,0 +1,262 @@ +//! Like ngtsc, a constructor parameter decorator (`@Inject`, `@Optional`, +//! `@Self`, `@SkipSelf`, `@Host`, `@Attribute`) is Angular's only when it's +//! imported from `@angular/core`: by name under any alias +//! (`import {Inject as Inj}`), or through a namespace import (`@ng.Inject()`). +//! Angular's are compiled into the factory, `setClassMetadata` and JIT +//! `ctorParameters`, and removed. Another module's or a local one with the same +//! name is ignored by all three and left on the parameter (JIT lowers it as +//! `__param(index, decorator)` in the class's `__decorate`, as TypeScript does). +//! +//! `fixtures/ctor_param_decorators_ngtsc.json` is a snapshot of +//! `@angular/compiler-cli` 22.1.7 for class `A` in each probe's `test.ts` (the +//! other files are what it imports): its diagnostics, the arguments of its +//! factory, the `ctorParameters` of `setClassMetadata`, the parameter +//! decorators left (as the `__param(...)` TypeScript lowers them to), and the +//! same two for Angular's JIT transform (`constructorParametersDownlevelTransform`). +//! Text is compared without whitespace, with `'` as `"` and without trailing +//! commas. Cases oxc deliberately handles differently carry a `skip` reason. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, TransformResult, transform_angular_file}; +use serde_json::Value; + +const FIXTURES: &str = include_str!("fixtures/ctor_param_decorators_ngtsc.json"); + +fn transform(source: &str, options: &TransformOptions) -> TransformResult { + let allocator = Allocator::default(); + transform_angular_file(&allocator, "test.ts", source, Some(options), None) +} + +/// `text` without whitespace, with `'` as `"`, without trailing commas and +/// without ngtsc's `/* @ts-ignore */`. +fn normalize(text: &str) -> String { + let text: String = text + .replace("/* @ts-ignore */", "") + .chars() + .filter(|c| !c.is_whitespace()) + .map(|c| if c == '\'' { '"' } else { c }) + .collect(); + let mut out = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if c == ',' && chars.get(i + 1).is_some_and(|n| matches!(n, ']' | '}' | ')')) { + continue; + } + out.push(c); + } + out +} + +/// The bracketed text opening at byte `open` of `text`, skipping strings. +fn bracketed(text: &str, open: usize) -> &str { + let bytes = text.as_bytes(); + let (mut depth, mut i) = (0, open); + while i < bytes.len() { + match bytes[i] { + q @ (b'\'' | b'"' | b'`') => { + i += 1; + while bytes[i] != q { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + } + b'[' | b'{' | b'(' => depth += 1, + b']' | b'}' | b')' => { + depth -= 1; + if depth == 0 { + return &text[open..=i]; + } + } + _ => {} + } + i += 1; + } + &text[open..] +} + +/// The arguments `A`'s factory constructs it with. oxc reads an `@Inject` +/// token imported from another file through its namespace import (`i1.TOKEN`), +/// where ngtsc names the import (`TOKEN`); both are the same value. +fn factory(js: &str) -> Option { + const NEW: &str = "(__ngFactoryType__ || A)"; + let at = js.find(&format!("{NEW}("))? + NEW.len(); + Some(normalize(bracketed(js, at)).replace("i1.TOKEN", "TOKEN")) +} + +/// The `ctorParameters` array of `setClassMetadata(A, ...)`. +fn ctor_parameters(js: &str) -> Option { + let at = js.find("setClassMetadata(A")?; + let arrow = at + js[at..].find("() =>")?; + let open = arrow + js[arrow..].find('[')?; + Some(normalize(bracketed(js, open))) +} + +/// The `ctorParameters` array of JIT output. +fn jit_ctor_parameters(js: &str) -> Option { + let at = js.find("ctorParameters = () =>")?; + let open = at + js[at..].find('[')?; + Some(normalize(bracketed(js, open))) +} + +/// The `__param(index, decorator)` entries in `js`, sorted. +fn lowered_param_decorators(js: &str) -> Vec { + let mut found: Vec = js + .match_indices("__param(") + .map(|(at, _)| normalize(&format!("__param{}", bracketed(js, at + "__param".len())))) + .collect(); + found.sort(); + found +} + +/// The decorators left on `A`'s constructor parameters in AOT output, as the +/// `__param(index, decorator)` TypeScript lowers them to, sorted. +fn param_decorators_left(js: &str) -> Vec { + let Some(at) = js.find("constructor(") else { return Vec::new() }; + let params = bracketed(js, at + "constructor".len()); + let params = ¶ms[1..params.len() - 1]; + // Split at top-level commas. + let (mut list, mut depth, mut start) = (Vec::new(), 0, 0); + for (i, c) in params.char_indices() { + match c { + '(' | '[' | '{' => depth += 1, + ')' | ']' | '}' => depth -= 1, + ',' if depth == 0 => { + list.push(¶ms[start..i]); + start = i + 1; + } + _ => {} + } + } + list.push(¶ms[start..]); + + let mut left = Vec::new(); + for (index, param) in list.iter().enumerate() { + for (at, _) in param.match_indices('@') { + let name_end = param[at + 1..] + .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '.') + .map_or(param.len(), |end| at + 1 + end); + let mut decorator = param[at + 1..name_end].to_string(); + if param[name_end..].starts_with('(') { + decorator.push_str(bracketed(param, name_end)); + } + left.push(normalize(&format!("__param({index}, {decorator})"))); + } + } + left.sort(); + left +} + +fn strings(value: &Value) -> Vec { + value.as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect() +} + +fn optional(value: &Value) -> Option { + value.as_str().map(str::to_string) +} + +#[test] +fn ctor_param_decorators_match_ngtsc() { + let fixtures: Value = serde_json::from_str(FIXTURES).unwrap(); + let mut failures = Vec::new(); + let mut compared = 0; + for fixture in fixtures["fixtures"].as_array().unwrap() { + if fixture.get("skip").is_some() { + continue; + } + compared += 1; + let name = fixture["name"].as_str().unwrap(); + let source = fixture["files"]["test.ts"].as_str().unwrap(); + let mut check = |what: &str, ngtsc: String, oxc: String| { + if ngtsc != oxc { + failures.push(format!("{name}\n {what}\n ngtsc: {ngtsc}\n oxc: {oxc}")); + } + }; + + let aot = transform( + source, + &TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }, + ); + let errors: Vec = aot + .diagnostics + .iter() + .filter(|d| d.severity == oxc_diagnostics::Severity::Error) + .map(|d| d.message.to_string()) + .collect(); + check( + "diagnostics", + format!("{:?}", strings(&fixture["diagnostics"])), + format!("{errors:?}"), + ); + check( + "factory", + format!("{:?}", optional(&fixture["factory"])), + format!("{:?}", factory(&aot.code)), + ); + check( + "setClassMetadata ctorParameters", + format!("{:?}", optional(&fixture["ctorParameters"])), + format!("{:?}", ctor_parameters(&aot.code)), + ); + check( + "parameter decorators left", + format!("{:?}", strings(&fixture["paramDecoratorsLeft"])), + format!("{:?}", param_decorators_left(&aot.code)), + ); + + let jit = transform(source, &TransformOptions { jit: true, ..TransformOptions::default() }); + check( + "JIT ctorParameters", + format!("{:?}", optional(&fixture["jitCtorParameters"])), + format!("{:?}", jit_ctor_parameters(&jit.code)), + ); + check( + "JIT __param", + format!("{:?}", strings(&fixture["jitParamDecorators"])), + format!("{:?}", lowered_param_decorators(&jit.code)), + ); + } + assert!( + failures.is_empty(), + "{} mismatches with ngtsc:\n\n{}", + failures.len(), + failures.join("\n\n") + ); + assert_eq!(compared, 29, "fixtures compared"); +} + +/// A parameter decorator left in the output keeps its import: another module's +/// `@Inject` / `@Optional` (and the `@Inject` argument) aren't Angular's, so +/// they aren't removed, and neither is what they need at runtime. +#[test] +fn foreign_param_decorators_keep_their_imports() { + let source = "import {Component} from '@angular/core';\nimport {Inject, Optional} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep) {}\n}\n"; + let result = transform(source, &TransformOptions::default()); + assert!(result.code.contains("import {Inject, Optional} from './foreign';"), "{}", result.code); + assert!(result.code.contains("TOKEN } from"), "{}", result.code); + assert!( + result.code.contains("constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep)"), + "{}", + result.code + ); +} + +/// JIT output imports `__param` from tslib only when it lowers a parameter +/// decorator with it. +#[test] +fn jit_imports_param_helper_only_when_used() { + let options = TransformOptions { jit: true, ..TransformOptions::default() }; + let angular = transform( + "import {Component, Optional} from '@angular/core';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Optional() a: Dep) {}\n}\n", + &options, + ); + assert!(angular.code.contains("import { __decorate } from \"tslib\";"), "{}", angular.code); + + let foreign = transform( + "import {Component} from '@angular/core';\nimport {Optional} from './foreign';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Optional() a: Dep) {}\n}\n", + &options, + ); + assert!( + foreign.code.contains("import { __decorate, __param } from \"tslib\";"), + "{}", + foreign.code + ); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/ctor_param_decorators_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/ctor_param_decorators_ngtsc.json new file mode 100644 index 000000000..405a8df55 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/fixtures/ctor_param_decorators_ngtsc.json @@ -0,0 +1,681 @@ +{ + "compilerCli": "22.1.7", + "fixtures": [ + { + "name": "ctor-cmp-named", + "files": { + "test.ts": "import {Component, Inject, Optional, Self, SkipSelf, Host, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-cmp-alias", + "files": { + "test.ts": "import {Component as Dcl, Inject as InX, Optional as OpX, Self as Slf, SkipSelf as SkS, Host as HoX, Attribute as AtX} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Dcl({selector: 'a-c', template: ''})\nexport class A {\n constructor(@InX(TOKEN) a: Dep, @OpX() b: Dep, @Slf() c: Dep, @SkS() d: Dep, @HoX() e: Dep, @AtX('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:OpX}]},{type:i1.Dep,decorators:[{type:Slf}]},{type:i1.Dep,decorators:[{type:SkS}]},{type:i1.Dep,decorators:[{type:HoX}]},{type:i1.Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:Dep,decorators:[{type:OpX}]},{type:Dep,decorators:[{type:Slf}]},{type:Dep,decorators:[{type:SkS}]},{type:Dep,decorators:[{type:HoX}]},{type:Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-cmp-ns", + "files": { + "test.ts": "import * as ng from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@ng.Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@ng.Inject(TOKEN) a: Dep, @ng.Optional() b: Dep, @ng.Self() c: Dep, @ng.SkipSelf() d: Dep, @ng.Host() e: Dep, @ng.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:ng.Optional}]},{type:i1.Dep,decorators:[{type:ng.Self}]},{type:i1.Dep,decorators:[{type:ng.SkipSelf}]},{type:i1.Dep,decorators:[{type:ng.Host}]},{type:i1.Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:ng.Optional}]},{type:Dep,decorators:[{type:ng.Self}]},{type:Dep,decorators:[{type:ng.SkipSelf}]},{type:Dep,decorators:[{type:ng.Host}]},{type:Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-cmp-foreign", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {Inject, Optional, Self, SkipSelf, Host, Attribute} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-cmp-local", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\nfunction Inject(t: any): ParameterDecorator { return () => {}; }\nfunction Optional(): ParameterDecorator { return () => {}; }\nfunction Self(): ParameterDecorator { return () => {}; }\nfunction SkipSelf(): ParameterDecorator { return () => {}; }\nfunction Host(): ParameterDecorator { return () => {}; }\nfunction Attribute(n: string): ParameterDecorator { return () => {}; }\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-cmp-fns", + "files": { + "test.ts": "import {Component} from '@angular/core';\nimport * as f from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@f.Inject(TOKEN) a: Dep, @f.Optional() b: Dep, @f.Self() c: Dep, @f.SkipSelf() d: Dep, @f.Host() e: Dep, @f.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ] + }, + { + "name": "ctor-dir-named", + "files": { + "test.ts": "import {Directive, Inject, Optional, Self, SkipSelf, Host, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Directive({selector: '[a]'})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-dir-alias", + "files": { + "test.ts": "import {Directive as Dcl, Inject as InX, Optional as OpX, Self as Slf, SkipSelf as SkS, Host as HoX, Attribute as AtX} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Dcl({selector: '[a]'})\nexport class A {\n constructor(@InX(TOKEN) a: Dep, @OpX() b: Dep, @Slf() c: Dep, @SkS() d: Dep, @HoX() e: Dep, @AtX('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:OpX}]},{type:i1.Dep,decorators:[{type:Slf}]},{type:i1.Dep,decorators:[{type:SkS}]},{type:i1.Dep,decorators:[{type:HoX}]},{type:i1.Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:Dep,decorators:[{type:OpX}]},{type:Dep,decorators:[{type:Slf}]},{type:Dep,decorators:[{type:SkS}]},{type:Dep,decorators:[{type:HoX}]},{type:Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-dir-ns", + "files": { + "test.ts": "import * as ng from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@ng.Directive({selector: '[a]'})\nexport class A {\n constructor(@ng.Inject(TOKEN) a: Dep, @ng.Optional() b: Dep, @ng.Self() c: Dep, @ng.SkipSelf() d: Dep, @ng.Host() e: Dep, @ng.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:ng.Optional}]},{type:i1.Dep,decorators:[{type:ng.Self}]},{type:i1.Dep,decorators:[{type:ng.SkipSelf}]},{type:i1.Dep,decorators:[{type:ng.Host}]},{type:i1.Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:ng.Optional}]},{type:Dep,decorators:[{type:ng.Self}]},{type:Dep,decorators:[{type:ng.SkipSelf}]},{type:Dep,decorators:[{type:ng.Host}]},{type:Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-dir-foreign", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {Inject, Optional, Self, SkipSelf, Host, Attribute} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Directive({selector: '[a]'})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-dir-local", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\nfunction Inject(t: any): ParameterDecorator { return () => {}; }\nfunction Optional(): ParameterDecorator { return () => {}; }\nfunction Self(): ParameterDecorator { return () => {}; }\nfunction SkipSelf(): ParameterDecorator { return () => {}; }\nfunction Host(): ParameterDecorator { return () => {}; }\nfunction Attribute(n: string): ParameterDecorator { return () => {}; }\n@Directive({selector: '[a]'})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-dir-fns", + "files": { + "test.ts": "import {Directive} from '@angular/core';\nimport * as f from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Directive({selector: '[a]'})\nexport class A {\n constructor(@f.Inject(TOKEN) a: Dep, @f.Optional() b: Dep, @f.Self() c: Dep, @f.SkipSelf() d: Dep, @f.Host() e: Dep, @f.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ] + }, + { + "name": "ctor-inj-named", + "files": { + "test.ts": "import {Injectable, Inject, Optional, Self, SkipSelf, Host, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Injectable()\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(TOKEN),i0.ɵɵinject(i1.Dep,8),i0.ɵɵinject(i1.Dep,2),i0.ɵɵinject(i1.Dep,4),i0.ɵɵinject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-inj-alias", + "files": { + "test.ts": "import {Injectable as Dcl, Inject as InX, Optional as OpX, Self as Slf, SkipSelf as SkS, Host as HoX, Attribute as AtX} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Dcl()\nexport class A {\n constructor(@InX(TOKEN) a: Dep, @OpX() b: Dep, @Slf() c: Dep, @SkS() d: Dep, @HoX() e: Dep, @AtX('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": null, + "ctorParameters": null, + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:Dep,decorators:[{type:OpX}]},{type:Dep,decorators:[{type:Slf}]},{type:Dep,decorators:[{type:SkS}]},{type:Dep,decorators:[{type:HoX}]},{type:Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "jitParamDecorators": [], + "skip": "ngtsc gives an aliased `@Injectable` no factory (`needsFactory` reads the decorator's written name), so it has no `ɵfac` to compare; oxc emits one (see `class_decorator_identity_test`)" + }, + { + "name": "ctor-inj-ns", + "files": { + "test.ts": "import * as ng from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@ng.Injectable()\nexport class A {\n constructor(@ng.Inject(TOKEN) a: Dep, @ng.Optional() b: Dep, @ng.Self() c: Dep, @ng.SkipSelf() d: Dep, @ng.Host() e: Dep, @ng.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(TOKEN),i0.ɵɵinject(i1.Dep,8),i0.ɵɵinject(i1.Dep,2),i0.ɵɵinject(i1.Dep,4),i0.ɵɵinject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:ng.Optional}]},{type:i1.Dep,decorators:[{type:ng.Self}]},{type:i1.Dep,decorators:[{type:ng.SkipSelf}]},{type:i1.Dep,decorators:[{type:ng.Host}]},{type:i1.Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:ng.Optional}]},{type:Dep,decorators:[{type:ng.Self}]},{type:Dep,decorators:[{type:ng.SkipSelf}]},{type:Dep,decorators:[{type:ng.Host}]},{type:Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-inj-foreign", + "files": { + "test.ts": "import {Injectable} from '@angular/core';\nimport {Inject, Optional, Self, SkipSelf, Host, Attribute} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Injectable()\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-inj-local", + "files": { + "test.ts": "import {Injectable} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\nfunction Inject(t: any): ParameterDecorator { return () => {}; }\nfunction Optional(): ParameterDecorator { return () => {}; }\nfunction Self(): ParameterDecorator { return () => {}; }\nfunction SkipSelf(): ParameterDecorator { return () => {}; }\nfunction Host(): ParameterDecorator { return () => {}; }\nfunction Attribute(n: string): ParameterDecorator { return () => {}; }\n@Injectable()\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-inj-fns", + "files": { + "test.ts": "import {Injectable} from '@angular/core';\nimport * as f from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Injectable()\nexport class A {\n constructor(@f.Inject(TOKEN) a: Dep, @f.Optional() b: Dep, @f.Self() c: Dep, @f.SkipSelf() d: Dep, @f.Host() e: Dep, @f.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ] + }, + { + "name": "ctor-pipe-named", + "files": { + "test.ts": "import {Pipe, Inject, Optional, Self, SkipSelf, Host, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN,16),i0.ɵɵdirectiveInject(i1.Dep,24),i0.ɵɵdirectiveInject(i1.Dep,18),i0.ɵɵdirectiveInject(i1.Dep,20),i0.ɵɵdirectiveInject(i1.Dep,17),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-pipe-alias", + "files": { + "test.ts": "import {Pipe as Dcl, Inject as InX, Optional as OpX, Self as Slf, SkipSelf as SkS, Host as HoX, Attribute as AtX} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Dcl({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@InX(TOKEN) a: Dep, @OpX() b: Dep, @Slf() c: Dep, @SkS() d: Dep, @HoX() e: Dep, @AtX('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN,16),i0.ɵɵdirectiveInject(i1.Dep,24),i0.ɵɵdirectiveInject(i1.Dep,18),i0.ɵɵdirectiveInject(i1.Dep,20),i0.ɵɵdirectiveInject(i1.Dep,17),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:OpX}]},{type:i1.Dep,decorators:[{type:Slf}]},{type:i1.Dep,decorators:[{type:SkS}]},{type:i1.Dep,decorators:[{type:HoX}]},{type:i1.Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:Dep,decorators:[{type:OpX}]},{type:Dep,decorators:[{type:Slf}]},{type:Dep,decorators:[{type:SkS}]},{type:Dep,decorators:[{type:HoX}]},{type:Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-pipe-ns", + "files": { + "test.ts": "import * as ng from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@ng.Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@ng.Inject(TOKEN) a: Dep, @ng.Optional() b: Dep, @ng.Self() c: Dep, @ng.SkipSelf() d: Dep, @ng.Host() e: Dep, @ng.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN,16),i0.ɵɵdirectiveInject(i1.Dep,24),i0.ɵɵdirectiveInject(i1.Dep,18),i0.ɵɵdirectiveInject(i1.Dep,20),i0.ɵɵdirectiveInject(i1.Dep,17),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:ng.Optional}]},{type:i1.Dep,decorators:[{type:ng.Self}]},{type:i1.Dep,decorators:[{type:ng.SkipSelf}]},{type:i1.Dep,decorators:[{type:ng.Host}]},{type:i1.Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:ng.Optional}]},{type:Dep,decorators:[{type:ng.Self}]},{type:Dep,decorators:[{type:ng.SkipSelf}]},{type:Dep,decorators:[{type:ng.Host}]},{type:Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-pipe-foreign", + "files": { + "test.ts": "import {Pipe} from '@angular/core';\nimport {Inject, Optional, Self, SkipSelf, Host, Attribute} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-pipe-local", + "files": { + "test.ts": "import {Pipe} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\nfunction Inject(t: any): ParameterDecorator { return () => {}; }\nfunction Optional(): ParameterDecorator { return () => {}; }\nfunction Self(): ParameterDecorator { return () => {}; }\nfunction SkipSelf(): ParameterDecorator { return () => {}; }\nfunction Host(): ParameterDecorator { return () => {}; }\nfunction Attribute(n: string): ParameterDecorator { return () => {}; }\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-pipe-fns", + "files": { + "test.ts": "import {Pipe} from '@angular/core';\nimport * as f from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Pipe({name: 'p'})\nexport class A { transform(v: any) { return v; }\n constructor(@f.Inject(TOKEN) a: Dep, @f.Optional() b: Dep, @f.Self() c: Dep, @f.SkipSelf() d: Dep, @f.Host() e: Dep, @f.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16),i0.ɵɵdirectiveInject(i1.Dep,16))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ] + }, + { + "name": "ctor-mod-named", + "files": { + "test.ts": "import {NgModule, Inject, Optional, Self, SkipSelf, Host, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@NgModule({})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(TOKEN),i0.ɵɵinject(i1.Dep,8),i0.ɵɵinject(i1.Dep,2),i0.ɵɵinject(i1.Dep,4),i0.ɵɵinject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [], + "skip": "pre-existing: oxc's `@NgModule` factory ignores `@Inject(TOKEN)` and injects the parameter's type, whatever the decorator is called" + }, + { + "name": "ctor-mod-alias", + "files": { + "test.ts": "import {NgModule as Dcl, Inject as InX, Optional as OpX, Self as Slf, SkipSelf as SkS, Host as HoX, Attribute as AtX} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Dcl({})\nexport class A {\n constructor(@InX(TOKEN) a: Dep, @OpX() b: Dep, @Slf() c: Dep, @SkS() d: Dep, @HoX() e: Dep, @AtX('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(TOKEN),i0.ɵɵinject(i1.Dep,8),i0.ɵɵinject(i1.Dep,2),i0.ɵɵinject(i1.Dep,4),i0.ɵɵinject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:OpX}]},{type:i1.Dep,decorators:[{type:Slf}]},{type:i1.Dep,decorators:[{type:SkS}]},{type:i1.Dep,decorators:[{type:HoX}]},{type:i1.Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:InX,args:[TOKEN]}]},{type:Dep,decorators:[{type:OpX}]},{type:Dep,decorators:[{type:Slf}]},{type:Dep,decorators:[{type:SkS}]},{type:Dep,decorators:[{type:HoX}]},{type:Dep,decorators:[{type:AtX,args:[\"x\"]}]}]", + "jitParamDecorators": [], + "skip": "pre-existing: see `ctor-mod-named`" + }, + { + "name": "ctor-mod-ns", + "files": { + "test.ts": "import * as ng from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@ng.NgModule({})\nexport class A {\n constructor(@ng.Inject(TOKEN) a: Dep, @ng.Optional() b: Dep, @ng.Self() c: Dep, @ng.SkipSelf() d: Dep, @ng.Host() e: Dep, @ng.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(TOKEN),i0.ɵɵinject(i1.Dep,8),i0.ɵɵinject(i1.Dep,2),i0.ɵɵinject(i1.Dep,4),i0.ɵɵinject(i1.Dep,1),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:ng.Optional}]},{type:i1.Dep,decorators:[{type:ng.Self}]},{type:i1.Dep,decorators:[{type:ng.SkipSelf}]},{type:i1.Dep,decorators:[{type:ng.Host}]},{type:i1.Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:ng.Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:ng.Optional}]},{type:Dep,decorators:[{type:ng.Self}]},{type:Dep,decorators:[{type:ng.SkipSelf}]},{type:Dep,decorators:[{type:ng.Host}]},{type:Dep,decorators:[{type:ng.Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [], + "skip": "pre-existing: see `ctor-mod-named`" + }, + { + "name": "ctor-mod-foreign", + "files": { + "test.ts": "import {NgModule} from '@angular/core';\nimport {Inject, Optional, Self, SkipSelf, Host, Attribute} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@NgModule({})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-mod-local", + "files": { + "test.ts": "import {NgModule} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\nfunction Inject(t: any): ParameterDecorator { return () => {}; }\nfunction Optional(): ParameterDecorator { return () => {}; }\nfunction Self(): ParameterDecorator { return () => {}; }\nfunction SkipSelf(): ParameterDecorator { return () => {}; }\nfunction Host(): ParameterDecorator { return () => {}; }\nfunction Attribute(n: string): ParameterDecorator { return () => {}; }\n@NgModule({})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,Inject(TOKEN))", + "__param(1,Optional())", + "__param(2,Self())", + "__param(3,SkipSelf())", + "__param(4,Host())", + "__param(5,Attribute(\"x\"))" + ] + }, + { + "name": "ctor-mod-fns", + "files": { + "test.ts": "import {NgModule} from '@angular/core';\nimport * as f from './foreign';\nimport {Dep, TOKEN} from './dep';\n@NgModule({})\nexport class A {\n constructor(@f.Inject(TOKEN) a: Dep, @f.Optional() b: Dep, @f.Self() c: Dep, @f.SkipSelf() d: Dep, @f.Host() e: Dep, @f.Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep),i0.ɵɵinject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[]}]", + "paramDecoratorsLeft": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ], + "jitCtorParameters": "[{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep},{type:Dep}]", + "jitParamDecorators": [ + "__param(0,f.Inject(TOKEN))", + "__param(1,f.Optional())", + "__param(2,f.Self())", + "__param(3,f.SkipSelf())", + "__param(4,f.Host())", + "__param(5,f.Attribute(\"x\"))" + ] + }, + { + "name": "ctor-cmp-mixed", + "files": { + "test.ts": "import {Component, Inject as Inj, Optional, Attribute} from '@angular/core';\nimport {Self} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inj(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @Optional() d: Dep, @Self() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inj,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": ["__param(2,Self())", "__param(4,Self())"], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inj,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep},{type:Dep,decorators:[{type:Optional}]},{type:Dep},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": ["__param(2,Self())", "__param(4,Self())"] + }, + { + "name": "ctor-cmp-swapped", + "files": { + "test.ts": "import {Component, Optional as Self, Self as Optional, Inject, SkipSelf as Host, Host as SkipSelf, Attribute} from '@angular/core';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Inject(TOKEN) a: Dep, @Optional() b: Dep, @Self() c: Dep, @SkipSelf() d: Dep, @Host() e: Dep, @Attribute('x') f: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep,2),i0.ɵɵdirectiveInject(i1.Dep,8),i0.ɵɵdirectiveInject(i1.Dep,1),i0.ɵɵdirectiveInject(i1.Dep,4),i0.ɵɵinjectAttribute(\"x\"))", + "ctorParameters": "[{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep,decorators:[{type:Optional}]},{type:i1.Dep,decorators:[{type:Self}]},{type:i1.Dep,decorators:[{type:SkipSelf}]},{type:i1.Dep,decorators:[{type:Host}]},{type:i1.Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "paramDecoratorsLeft": [], + "jitCtorParameters": "[{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep,decorators:[{type:Optional}]},{type:Dep,decorators:[{type:Self}]},{type:Dep,decorators:[{type:SkipSelf}]},{type:Dep,decorators:[{type:Host}]},{type:Dep,decorators:[{type:Attribute,args:[\"x\"]}]}]", + "jitParamDecorators": [] + }, + { + "name": "ctor-cmp-other", + "files": { + "test.ts": "import {Component, Inject} from '@angular/core';\nimport {Foo} from './foreign';\nimport {Dep, TOKEN} from './dep';\n@Component({selector: 'a-c', template: ''})\nexport class A {\n constructor(@Foo() a: Dep, @Foo() @Inject(TOKEN) b: Dep, c: Dep) {}\n}\n", + "dep.ts": "export class Dep {}\nexport const TOKEN = 'tok';\n", + "foreign.ts": "export function Inject(t: any): ParameterDecorator { return () => {}; }\nexport function Optional(): ParameterDecorator { return () => {}; }\nexport function Self(): ParameterDecorator { return () => {}; }\nexport function SkipSelf(): ParameterDecorator { return () => {}; }\nexport function Host(): ParameterDecorator { return () => {}; }\nexport function Attribute(n: string): ParameterDecorator { return () => {}; }\nexport function Foo(): ParameterDecorator { return () => {}; }\n" + }, + "diagnostics": [], + "factory": "(i0.ɵɵdirectiveInject(i1.Dep),i0.ɵɵdirectiveInject(TOKEN),i0.ɵɵdirectiveInject(i1.Dep))", + "ctorParameters": "[{type:i1.Dep,decorators:[]},{type:i1.Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:i1.Dep}]", + "paramDecoratorsLeft": ["__param(0,Foo())", "__param(1,Foo())"], + "jitCtorParameters": "[{type:Dep},{type:Dep,decorators:[{type:Inject,args:[TOKEN]}]},{type:Dep}]", + "jitParamDecorators": ["__param(0,Foo())", "__param(1,Foo())"] + } + ] +} diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 533ef3a94..1528c7e26 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -22,7 +22,7 @@ use oxc_angular_compiler::{ AngularVersion as RustAngularVersion, ChangeDetectionStrategy as RustChangeDetectionStrategy, HostMetadataInput as RustHostMetadataInput, TransformOptions as RustTransformOptions, ViewEncapsulation as RustViewEncapsulation, - build_ctor_params_metadata as core_build_ctor_params_metadata, + build_ctor_params_metadata_in as core_build_ctor_params_metadata, build_decorator_metadata_array as core_build_decorator_metadata_array, build_prop_decorators_metadata_in as core_build_prop_decorators_metadata, compile_template_for_hmr, compile_template_to_js_with_options, @@ -2098,6 +2098,7 @@ pub fn compile_class_metadata_sync( &mut namespace_registry, &empty_import_map, Some(&source), + Some(&string_consts), ); // Build property decorators metadata. From 94bfc89b33ab8482ab8e9877a69e4aa90d5f0146 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 15:07:14 +0800 Subject: [PATCH 12/12] fix(compiler): hoist for an aliased `@Service`, and keep another module's `@Inject` on JIT members The hoist that moves a declaration an Angular decorator references above the class still required `@Service` to be written as `Service`, so `import { Service as NgService }` + `@NgService({ factory: FACTORY })` with a later `const FACTORY` was compiled but not hoisted, and the class's `setClassMetadata` read `FACTORY` in its TDZ. It now takes the import's exported name, like the compile path. JIT member decorators named like Angular's (`Inject`, `Component`, `Optional`, ...) were dropped whatever module they came from. Like ngtsc's JIT transform, only `@angular/core`'s are now; another module's stays a `__decorate()` call, and `@angular/core`'s `Inject` under an alias is treated like `@Inject`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/hoist.rs | 16 ++++--- .../src/component/transform.rs | 44 ++++++++----------- .../oxc_angular_compiler/src/directive/mod.rs | 8 ++-- .../src/directive/property_decorators.rs | 2 +- .../tests/class_decorator_identity_test.rs | 30 +++++++++++++ .../tests/jit_signal_initializer_test.rs | 39 ++++++++++++++++ 6 files changed, 101 insertions(+), 38 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/hoist.rs b/crates/oxc_angular_compiler/src/component/hoist.rs index 9e4f372c5..1f12add6a 100644 --- a/crates/oxc_angular_compiler/src/component/hoist.rs +++ b/crates/oxc_angular_compiler/src/component/hoist.rs @@ -892,10 +892,11 @@ fn class_of<'a, 'src>(stmt: &'src Statement<'a>) -> Option<(&'src Class<'a>, u32 /// Whether this class carries an Angular decorator that warrants hoisting its /// referenced declarations: one the compiler compiles the class for, imported /// from `@angular/core` (see [`crate::directive::angular_class_decorator`]), -/// or an `@Service` that resolves to `@angular/core`. A same-named decorator -/// from another library (`Service` is a common name in non-Angular code) -/// leaves the class alone, and hoisting its referenced declarations would -/// reorder statements and change that class's runtime evaluation semantics. +/// or an `@Service` that resolves to `@angular/core` (under any alias, or +/// through a namespace import). A same-named decorator from another library +/// (`Service` is a common name in non-Angular code) leaves the class alone, and +/// hoisting its referenced declarations would reorder statements and change +/// that class's runtime evaluation semantics. fn has_hoistable_angular_decorator<'a>( class: &Class<'a>, import_map: &ImportMap<'a>, @@ -910,9 +911,10 @@ fn has_hoistable_angular_decorator<'a>( expr => expr, }; match callee { - Expression::Identifier(id) => { - id.name == "Service" && is_angular_core_export(import_map, &id.name, "Service") - } + // `@Service()`, or `@NgService()` for `import { Service as NgService }`: + // like the compile path (`find_angular_decorator`), the import's + // exported name decides, not the local one. + Expression::Identifier(id) => is_angular_core_export(import_map, &id.name, "Service"), // `@ns.Service()` — accept only when `ns` is a namespace import // from `@angular/core`. Expression::StaticMemberExpression(member) => { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 6bbde6ae9..a0bd22ea9 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -1131,23 +1131,12 @@ fn extract_jit_ctor_params( (params, other_decorators) } -/// Angular field decorators that go into `static propDecorators`. -/// Matches Angular's official `FIELD_DECORATORS` constant from `@angular/compiler-cli`. -const ANGULAR_FIELD_DECORATORS: &[&str] = &[ - "Input", - "Output", - "HostBinding", - "HostListener", - "ViewChild", - "ViewChildren", - "ContentChild", - "ContentChildren", -]; - -/// All Angular decorator names from `@angular/core`. -/// Any decorator with one of these names is treated as Angular and excluded from -/// non-Angular `__decorate()` lowering. Angular identifies decorators by import source; -/// we use names since they're unique to `@angular/core`. +/// Angular's decorators. On a member, one imported from `@angular/core` (see +/// [`crate::directive::angular_core_decorator`]) goes to `propDecorators` if it's +/// a field decorator and is dropped otherwise (ngtsc lists those in +/// `propDecorators` too). Like ngtsc's JIT transform, where a decorator comes +/// from decides, not its name: another module's `@Inject` stays a `__decorate()` +/// call. const ANGULAR_DECORATOR_NAMES: &[&str] = &[ // Field decorators (→ propDecorators) "Input", @@ -1241,11 +1230,10 @@ fn extract_all_jit_member_decorators( } expr => (expr, None), }; - let dec_name = match callee { - Expression::Identifier(id) => id.name.as_str(), - Expression::StaticMemberExpression(m) => m.property.name.as_str(), - _ => continue, - }; + if !matches!(callee, Expression::Identifier(_) | Expression::StaticMemberExpression(_)) + { + continue; + } if let Some(field) = crate::directive::angular_member_decorator(decorator, Some(string_consts)) @@ -1256,11 +1244,15 @@ fn extract_all_jit_member_decorators( let name = source[callee.span().start as usize..callee.span().end as usize].to_string(); angular_decs.push(JitParamDecorator { name, args: call_args }); - } else if ANGULAR_FIELD_DECORATORS.contains(&dec_name) - || !ANGULAR_DECORATOR_NAMES.contains(&dec_name) + } else if crate::directive::angular_core_decorator( + decorator, + Some(string_consts), + ANGULAR_DECORATOR_NAMES, + ) + .is_none() { - // Non-Angular decorator (another module's `@Input` too) → goes into - // __decorate() call + // Non-Angular decorator (another module's `@Input` or `@Inject` + // too) → goes into __decorate() call let expr_start = decorator.expression.span().start; let expr_end = decorator.expression.span().end; non_angular_texts.push(source[expr_start as usize..expr_end as usize].to_string()); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index becf394de..172f7222c 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -47,10 +47,10 @@ pub use metadata::{ R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; pub(crate) use property_decorators::{ - angular_class_decorator, angular_member_decorator, angular_param_decorator, - extract_host_bindings_in, extract_host_listeners_in, parse_decorator_queries, - try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, - unwrap_initializer_api_expr, + angular_class_decorator, angular_core_decorator, angular_member_decorator, + angular_param_decorator, extract_host_bindings_in, extract_host_listeners_in, + parse_decorator_queries, try_parse_signal_input, try_parse_signal_model, + try_parse_signal_output, unwrap_initializer_api_expr, }; pub use property_decorators::{ extract_class_queries, extract_content_queries, extract_host_bindings, extract_host_listeners, diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 9fad583d0..902ddc1ed 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -63,7 +63,7 @@ const CLASS_DECORATORS: &[&str] = &["Component", "Directive", "Pipe", "Injectabl /// (`@core.Input()`, see [`is_core_namespace`]); another module's (even one /// re-exporting Angular's), a local or an undeclared `X` isn't Angular's. /// Without the file, any decorator with that name. -fn angular_core_decorator( +pub(crate) fn angular_core_decorator( decorator: &Decorator<'_>, consts: Option<&super::StringConsts<'_>>, names: &[&'static str], diff --git a/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs index 4c2124b1d..8baf43301 100644 --- a/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs +++ b/crates/oxc_angular_compiler/tests/class_decorator_identity_test.rs @@ -243,6 +243,36 @@ fn only_angular_class_decorators_hoist_their_references() { assert!(!hoisted(&foreign.code), "{}", foreign.code); } +/// `@Service` is Angular's under any alias too (`@NgService()` for +/// `import {Service as NgService}`), like ngtsc's `findAngularDecorator`: the +/// declaration its metadata references after the class is hoisted above it, +/// as for `@Service`, so the compiled class doesn't read it in its TDZ. +/// Another library's `Service` under the same alias isn't compiled, so the +/// file's statements keep their order. +#[test] +fn aliased_service_hoists_its_references() { + let source = |import: &str| { + format!( + "{import}\n@NgService({{factory: FACTORY}})\nexport class A {{}}\nconst FACTORY = () => new A();\n" + ) + }; + let hoisted = |code: &str| code.find("const FACTORY").unwrap() < code.find("class A").unwrap(); + + let aliased = transform( + &source("import {Service as NgService} from '@angular/core';"), + &TransformOptions::default(), + ); + assert!(aliased.code.contains("static ɵprov"), "{}", aliased.code); + assert!(hoisted(&aliased.code), "{}", aliased.code); + + let foreign = transform( + &source("import {Service as NgService} from './di';"), + &TransformOptions::default(), + ); + assert!(!foreign.code.contains("static ɵprov"), "{}", foreign.code); + assert!(!hoisted(&foreign.code), "{}", foreign.code); +} + /// With HMR on, each compiled component's `@Component` is reported as written, /// so a build tool can find the decorator the compiler took. Another library's /// `@Component` isn't compiled, so it isn't reported. diff --git a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs index 03dc2c717..3f20f49f5 100644 --- a/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs +++ b/crates/oxc_angular_compiler/tests/jit_signal_initializer_test.rs @@ -295,3 +295,42 @@ fn member_decorators_are_lowered_only_when_imported_from_angular_core() { ); assert!(compact.contains(r#"__decorate([Input()],C.prototype,"c",void0);"#), "Got:\n{out}"); } + +#[test] +fn member_decorators_named_like_angular_ones_count_only_from_angular_core() { + // Like ngtsc's JIT transform, where a member decorator comes from decides, + // not its name: another module's `@Inject` / `@Component` / `@ns.Optional` + // stay decorators applied with `__decorate` (they used to be dropped). + let out = compile_jit( + "import { Component } from '@angular/core';\n\ + import { Inject, Component as Cmp } from './di';\n\ + import * as di from './di';\n\ + @Component({ selector: 'c', template: '', standalone: true })\n\ + export class C {\n @Inject(X) a: any;\n @Cmp({}) b: any;\n @di.Optional() c: any;\n}\n", + ); + let compact: String = out.chars().filter(|c| !c.is_whitespace()).collect(); + assert!(compact.contains(r#"__decorate([Inject(X)],C.prototype,"a",void0);"#), "Got:\n{out}"); + assert!(compact.contains(r#"__decorate([Cmp({})],C.prototype,"b",void0);"#), "Got:\n{out}"); + assert!( + compact.contains(r#"__decorate([di.Optional()],C.prototype,"c",void0);"#), + "Got:\n{out}" + ); + + // `@angular/core`'s `Inject` under an alias is treated like `@Inject`. + let aliased = compile_jit( + "import { Component, Inject as Inj } from '@angular/core';\n\ + @Component({ selector: 'c', template: '', standalone: true })\n\ + export class C {\n @Inj(X) a: any;\n}\n", + ); + let plain = compile_jit( + "import { Component, Inject } from '@angular/core';\n\ + @Component({ selector: 'c', template: '', standalone: true })\n\ + export class C {\n @Inject(X) a: any;\n}\n", + ); + assert!(!aliased.contains("Inj(X)"), "Got:\n{aliased}"); + assert_eq!( + aliased.replace("Inject as Inj", "Inject"), + plain, + "aliased:\n{aliased}\nplain:\n{plain}" + ); +}