From eccbb4fd4ce7c0bffa80d0aa2d0680e6f4c7fc67 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 13:34:00 +0100 Subject: [PATCH 1/8] feat(class_metadata): emit initializer-API prop decorators for JIT/TestBed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signal-based members (`input()`/`input.required()`/`output()`/`model()`/ `viewChild()`/`viewChildren()`/`contentChild()`/`contentChildren()`) live only in the AOT `ɵcmp`, which `TestBed.overrideComponent` discards before recompiling via JIT. The JIT recompile reconstructs inputs/outputs/queries from decorator and prop metadata reflected off the class, so signal members were silently dropped on recompile — `setInput`/router-binding then failed with NG0315/NG0303/NG0950. Synthesize the equivalent `@Input`/`@Output`/query prop decorators into `setClassMetadata`, mirroring `@angular/compiler-cli`'s `initializer_api_transforms` (applied by the Angular CLI in test builds). The decorator type is referenced via the `@angular/core` namespace import (`i0.Input`), exactly as ngc emits it; output verified byte-for-byte against ngc 21.2 across all initializer-API member kinds. --- .../src/class_metadata/builders.rs | 275 +++++++++++++++++- .../src/component/transform.rs | 1 + .../oxc_angular_compiler/src/directive/mod.rs | 3 + .../src/directive/property_decorators.rs | 12 +- .../tests/signal_member_jit_metadata_test.rs | 167 +++++++++++ ...put_from_observable_mixed_with_output.snap | 3 +- ...on_test__output_from_observable_piped.snap | 2 +- ...ut_from_observable_property_reference.snap | 2 +- ...n_test__output_from_observable_simple.snap | 2 +- ...st__output_from_observable_with_alias.snap | 2 +- napi/angular-compiler/src/lib.rs | 8 +- 11 files changed, 454 insertions(+), 23 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index 530e8771b..a7f29c7f5 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -11,6 +11,9 @@ use oxc_ast::ast::{ use oxc_str::Ident; use crate::component::{ImportMap, NamespaceRegistry, R3DependencyMetadata}; +use crate::directive::{ + R3InputMetadata, try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, +}; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, LiteralMapExpr, LiteralValue, OutputExpression, ReadPropExpr, ReadVarExpr, @@ -352,6 +355,7 @@ pub fn build_prop_decorators_metadata<'a>( allocator: &'a Allocator, class: &Class<'a>, source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, ) -> Option> { const ANGULAR_PROP_DECORATORS: &[&str] = &[ "Input", @@ -367,15 +371,15 @@ pub fn build_prop_decorators_metadata<'a>( let mut prop_entries = AllocVec::new_in(allocator); for element in &class.body.body { - let (decorators, property_name) = match element { + let (decorators, property_name, value) = match element { ClassElement::PropertyDefinition(prop) => { - (&prop.decorators, get_property_key_name(&prop.key)) + (&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref()) } ClassElement::MethodDefinition(method) => { - (&method.decorators, get_property_key_name(&method.key)) + (&method.decorators, get_property_key_name(&method.key), None) } ClassElement::AccessorProperty(prop) => { - (&prop.decorators, get_property_key_name(&prop.key)) + (&prop.decorators, get_property_key_name(&prop.key), prop.value.as_ref()) } _ => continue, }; @@ -393,15 +397,36 @@ pub fn build_prop_decorators_metadata<'a>( }) .collect(); - if angular_decorators.is_empty() { + if !angular_decorators.is_empty() { + // Build decorators array from the real decorators present in source. + let decorators_array = build_decorator_metadata_array( + allocator, + &angular_decorators, + source_text, + None, + None, + ); + prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); continue; } - // Build decorators array for this property - let decorators_array = - build_decorator_metadata_array(allocator, &angular_decorators, source_text, None, None); - - prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); + // No real Angular prop decorator. Synthesize one for initializer-API members + // (`input()`/`output()`/`model()`/`viewChild()`/…) so JIT recompilation + // (`TestBed.overrideComponent`) can reflect them — signal members live only in + // the AOT `ɵcmp`, which the JIT recompile discards. Mirrors Angular's + // compiler-cli `initializer_api_transforms` (applied by the Angular CLI in test + // builds); without it, `setInput`/router-binding fail with NG0315/NG0303/NG0950. + if let Some(value) = value + && let Some(decorators_array) = build_initializer_api_prop_decorators( + allocator, + value, + &prop_name, + source_text, + namespace_registry, + ) + { + prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); + } } if prop_entries.is_empty() { @@ -414,6 +439,236 @@ pub fn build_prop_decorators_metadata<'a>( ))) } +/// Build the synthetic prop-decorator array for a field initialized with an +/// Angular initializer API (`input()`, `output()`, `model()`, or a signal query). +/// Returns `None` when the initializer is not a recognized initializer API. +fn build_initializer_api_prop_decorators<'a>( + allocator: &'a Allocator, + value: &Expression<'a>, + property_name: &Ident<'a>, + source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> Option> { + let mut decorators = AllocVec::new_in(allocator); + + if let Some(input) = try_parse_signal_input(allocator, value, property_name.clone()) { + // 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()) { + // 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( + allocator, + namespace_registry, + "Output", + model.output.1.clone(), + )); + } else if let Some((_, binding)) = try_parse_signal_output(value, property_name.clone()) { + // output() / outputFromObservable() → `Output("")` + decorators.push(build_core_decorator_with_string_arg( + allocator, + namespace_registry, + "Output", + binding, + )); + } else if let Some(query) = + build_signal_query_decorator(allocator, value, source_text, namespace_registry) + { + decorators.push(query); + } + + if decorators.is_empty() { + return None; + } + + Some(OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: decorators, source_span: None }, + allocator, + ))) +} + +/// Build `{ type: i0.Input, args: [{ isSignal: true, alias, required }] }`. +/// +/// Matches the `setClassMetadata` shape emitted by `@angular/compiler-cli` (verified against +/// ngc's output) for both `input()`/`input.required()` and `model()`'s input: a three-field +/// config with no `transform` key (signal inputs handle transforms via the input signal at +/// runtime, so the decorator carries no transform). +fn build_signal_input_decorator<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + input: &R3InputMetadata<'a>, +) -> OutputExpression<'a> { + let mut config = AllocVec::new_in(allocator); + config.push(LiteralMapEntry::new(Ident::from("isSignal"), bool_literal(allocator, true), false)); + config.push(LiteralMapEntry::new( + Ident::from("alias"), + string_literal(allocator, input.binding_property_name.clone()), + false, + )); + config.push(LiteralMapEntry::new( + Ident::from("required"), + bool_literal(allocator, input.required), + false, + )); + + let mut args = AllocVec::new_in(allocator); + args.push(OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries: config, source_span: None }, + allocator, + ))); + build_core_decorator(allocator, namespace_registry, "Input", args) +} + +/// Build a query decorator from a signal-query initializer +/// (`viewChild`/`viewChildren`/`contentChild`/`contentChildren`), reusing the source +/// positional arguments: `Decorator(, { ..., isSignal: true })`. +/// Mirrors Angular's `queryFunctionsTransforms`. +fn build_signal_query_decorator<'a>( + allocator: &'a Allocator, + value: &Expression<'a>, + source_text: Option<&'a str>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> Option> { + let Expression::CallExpression(call) = value else { + return None; + }; + let decorator_name = signal_query_decorator_name(&call.callee)?; + + let mut args = AllocVec::new_in(allocator); + + // Predicate: the first positional argument, reused as-is. + if let Some(first) = call.arguments.first() + && let Some(predicate) = convert_oxc_expression(allocator, first.to_expression(), source_text) + { + args.push(predicate); + } + + // Options: merge the source options object (if any) with `isSignal: true`. + // Options: `{ ..., isSignal: true }`. Spread the second positional + // argument verbatim (matching Angular's `factory.createSpreadAssignment(callArgs[1])`), + // which preserves any options expression, object literal or not. + let mut options = AllocVec::new_in(allocator); + if let Some(second) = call.arguments.get(1) + && let Some(source_options) = + convert_oxc_expression(allocator, second.to_expression(), source_text) + { + options.push(LiteralMapEntry::spread(source_options)); + } + options.push(LiteralMapEntry::new(Ident::from("isSignal"), bool_literal(allocator, true), false)); + args.push(OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries: options, source_span: None }, + allocator, + ))); + + 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, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + arg: Ident<'a>, +) -> OutputExpression<'a> { + let mut args = AllocVec::new_in(allocator); + args.push(string_literal(allocator, arg)); + build_core_decorator(allocator, namespace_registry, decorator_name, args) +} + +/// Build a synthetic Angular core decorator metadata object: `{ type: i0., args: [...] }`. +/// The decorator type is referenced through the `@angular/core` namespace import (`i0`), since a +/// component using signal APIs imports `input`/`output`/… rather than the `Input`/`Output`/query +/// decorators themselves. +fn build_core_decorator<'a>( + allocator: &'a Allocator, + namespace_registry: &mut NamespaceRegistry<'a>, + decorator_name: &'static str, + args: AllocVec<'a, OutputExpression<'a>>, +) -> OutputExpression<'a> { + let core_namespace = namespace_registry.get_or_assign(&Ident::from("@angular/core")); + let type_expr = OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in( + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name: core_namespace, source_span: None }, + allocator, + )), + allocator, + ), + name: Ident::from(decorator_name), + optional: false, + source_span: None, + }, + allocator, + )); + + let mut entries = AllocVec::new_in(allocator); + entries.push(LiteralMapEntry::new(Ident::from("type"), type_expr, false)); + if !args.is_empty() { + entries.push(LiteralMapEntry::new( + Ident::from("args"), + OutputExpression::LiteralArray(Box::new_in( + LiteralArrayExpr { entries: args, source_span: None }, + allocator, + )), + false, + )); + } + + OutputExpression::LiteralMap(Box::new_in( + LiteralMapExpr { entries, source_span: None }, + allocator, + )) +} + +/// Build a boolean literal output expression. +fn bool_literal<'a>(allocator: &'a Allocator, value: bool) -> OutputExpression<'a> { + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::Boolean(value), source_span: None }, + allocator, + )) +} + +/// Build a string literal output expression. +fn string_literal<'a>(allocator: &'a Allocator, value: Ident<'a>) -> OutputExpression<'a> { + OutputExpression::Literal(Box::new_in( + LiteralExpr { value: LiteralValue::String(value), source_span: None }, + allocator, + )) +} + // ============================================================================ // Internal helper functions // ============================================================================ diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 181a211cb..67475d2b2 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2065,6 +2065,7 @@ pub fn transform_angular_file( allocator, class, Some(source), + &mut file_namespace_registry, ), }; diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index c91aec637..e5ad6f670 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -37,4 +37,7 @@ pub use property_decorators::{ extract_content_queries, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, extract_view_queries, }; +pub(crate) use property_decorators::{ + try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, +}; 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 937d090ec..69108da61 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -202,12 +202,12 @@ fn parse_input_config<'a>( /// Metadata for a model() signal, which creates both an input and an output. /// /// Based on Angular's `ModelMapping` in `model_function.ts`. -struct ModelMapping<'a> { +pub(crate) struct ModelMapping<'a> { /// The input metadata (signal-based). - input: R3InputMetadata<'a>, + pub(crate) input: R3InputMetadata<'a>, /// The output metadata (class property name, binding property name). /// Output binding name is always `inputName + "Change"`. - output: (Ident<'a>, Ident<'a>), + pub(crate) output: (Ident<'a>, Ident<'a>), } /// Try to detect and parse a signal-based model from a property initializer. @@ -223,7 +223,7 @@ struct ModelMapping<'a> { /// ``` /// /// Based on Angular's `model_function.ts` in the compiler-cli. -fn try_parse_signal_model<'a>( +pub(crate) fn try_parse_signal_model<'a>( allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, @@ -322,7 +322,7 @@ fn try_parse_signal_model<'a>( /// the observable expression is irrelevant for metadata extraction. /// /// Based on Angular's `output_function.ts` in the compiler-cli. -fn try_parse_signal_output<'a>( +pub(crate) fn try_parse_signal_output<'a>( value: &Expression<'a>, property_name: Ident<'a>, ) -> Option<(Ident<'a>, Ident<'a>)> { @@ -387,7 +387,7 @@ fn try_parse_signal_output<'a>( /// ``` /// /// Based on Angular's `input_function.ts` in the compiler-cli. -fn try_parse_signal_input<'a>( +pub(crate) fn try_parse_signal_input<'a>( _allocator: &'a Allocator, value: &Expression<'a>, property_name: Ident<'a>, 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 new file mode 100644 index 000000000..124f27009 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs @@ -0,0 +1,167 @@ +//! Tests for synthesizing prop-decorator metadata for initializer-API members +//! (`input()`, `output()`, `model()`, `viewChild()`/`contentChild()`/…) into +//! `ɵsetClassMetadata`. +//! +//! Angular's AOT `ɵcmp` carries signal members, but `TestBed.overrideComponent` +//! discards `ɵcmp` and recompiles via JIT, which reconstructs inputs/outputs/ +//! queries ONLY from decorator/prop metadata reflected off the class. Angular's +//! own CLI applies a JIT transform (compiler-cli `initializer_api_transforms/`) +//! that adds synthetic `@Input`/`@Output`/query decorators for signal members so +//! JIT can see them; without it, `setInput`/router-binding fail (NG0315/NG0950). +//! These tests assert OXC emits the equivalent synthetic prop decorators. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +/// Compile `source` with class metadata enabled and return the full output. +fn compile(source: &str) -> String { + let allocator = Allocator::default(); + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + result.code +} + +/// The slice of output from `ɵsetClassMetadata` onward (contains the decorators +/// array and the prop-decorators object). Asserting against this avoids matching +/// identifiers in the import statements or template. +fn metadata_region(code: &str) -> String { + let start = code + .find("\u{275}setClassMetadata") + .unwrap_or_else(|| panic!("ɵsetClassMetadata not present:\n{code}")); + code[start..].to_string() +} + +fn component(body: &str, imports: &str) -> String { + format!( + "import {{ Component, {imports} }} from '@angular/core';\n\n\ + @Component({{ selector: 'c', template: 'x', standalone: true }})\n\ + export class C {{\n{body}\n}}\n" + ) +} + +#[test] +fn signal_input_emits_input_prop_decorator_with_is_signal() { + let md = metadata_region(&compile(&component(" readonly value = input(0);", "input"))); + assert!(md.contains("value"), "prop key missing:\n{md}"); + assert!(md.contains("Input"), "synthetic Input decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "isSignal flag missing:\n{md}"); + assert!(md.contains("required:false"), "required flag missing:\n{md}"); + assert!(md.contains("alias:\"value\""), "alias missing:\n{md}"); + // ngc emits a three-field config `{isSignal, alias, required}` for signal inputs — + // NO `transform` key (verified against @angular/compiler-cli output). + assert!(!md.contains("transform"), "signal input must NOT emit a transform key:\n{md}"); +} + +#[test] +fn synthetic_decorator_uses_core_namespace_reference() { + // Angular references the synthetic decorator through the @angular/core namespace + // import (createSyntheticAngularCoreDecoratorAccess → `i0.Input`), not a bare `Input` + // identifier (a signal component imports `input`, not `Input`). + let md = metadata_region(&compile(&component(" readonly value = input(0);", "input"))); + assert!( + md.contains(".Input"), + "expected a namespaced `.Input` reference, not a bare identifier:\n{md}" + ); +} + +#[test] +fn signal_query_with_options_spreads_source_options() { + let md = metadata_region(&compile(&component( + " readonly items = contentChildren('item', { descendants: true });", + "contentChildren", + ))); + assert!(md.contains("ContentChildren"), "ContentChildren decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); + // The source options object is spread verbatim (matching Angular's + // `{ ...callArgs[1], isSignal: true }`). + assert!(md.contains("descendants"), "source query options not preserved:\n{md}"); +} + +#[test] +fn required_signal_input_marks_required() { + let md = metadata_region(&compile(&component( + " readonly value = input.required();", + "input", + ))); + assert!(md.contains("isSignal:true"), "isSignal missing:\n{md}"); + assert!(md.contains("required:true"), "required:true missing:\n{md}"); +} + +#[test] +fn aliased_signal_input_uses_alias() { + let md = metadata_region(&compile(&component( + " readonly value = input(0, { alias: 'publicName' });", + "input", + ))); + assert!(md.contains("alias:\"publicName\""), "alias not applied:\n{md}"); +} + +#[test] +fn signal_output_emits_output_prop_decorator() { + let md = metadata_region(&compile(&component(" readonly changed = output();", "output"))); + assert!(md.contains("changed"), "prop key missing:\n{md}"); + assert!(md.contains("Output"), "synthetic Output decorator missing:\n{md}"); + // output() lowers to `Output("")` (a single string arg). + assert!(md.contains("\"changed\""), "output binding name missing:\n{md}"); +} + +#[test] +fn signal_model_emits_input_and_output() { + let md = metadata_region(&compile(&component(" readonly open = model(false);", "model"))); + assert!(md.contains("Input"), "model Input decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "model Input isSignal missing:\n{md}"); + assert!(md.contains("Output"), "model Output decorator missing:\n{md}"); + assert!(md.contains("\"openChange\""), "model output binding `openChange` missing:\n{md}"); +} + +#[test] +fn signal_view_query_emits_view_child_with_is_signal() { + let md = metadata_region(&compile(&component( + " readonly ref = viewChild('tpl');", + "viewChild", + ))); + assert!(md.contains("ViewChild"), "synthetic ViewChild decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); +} + +#[test] +fn signal_content_query_emits_content_child_with_is_signal() { + let md = metadata_region(&compile(&component( + " readonly ref = contentChild('tpl');", + "contentChild", + ))); + assert!(md.contains("ContentChild"), "synthetic ContentChild decorator missing:\n{md}"); + assert!(md.contains("isSignal:true"), "query isSignal missing:\n{md}"); +} + +#[test] +fn classic_input_output_unchanged_and_not_signal() { + let md = metadata_region(&compile(&component( + " @Input() foo = 1;\n @Output() bar = new EventEmitter();", + "Input, Output, EventEmitter", + ))); + assert!(md.contains("foo"), "classic @Input prop key missing:\n{md}"); + assert!(md.contains("bar"), "classic @Output prop key missing:\n{md}"); + assert!(md.contains("type:Input"), "classic Input type missing:\n{md}"); + assert!( + !md.contains("isSignal"), + "classic decorators must not gain an isSignal flag:\n{md}" + ); +} + +#[test] +fn no_metadata_when_emit_disabled() { + let allocator = Allocator::default(); + let source = component(" readonly value = input(0);", "input"); + // emit_class_metadata is default-on, so disable it explicitly. + let options = TransformOptions { emit_class_metadata: false, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", &source, Some(&options), None); + assert!( + !result.code.contains("\u{275}setClassMetadata"), + "no setClassMetadata should be emitted when disabled:\n{}", + result.code + ); +} diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap index 14d726590..a969f13ad 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_mixed_with_output.snap @@ -26,5 +26,6 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{clicked:[{type:i0.Output,args:["clicked"]}],queryChanged:[{type:i0.Output, + args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap index 82c0ed1d7..e2a96f1d6 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_piped.snap @@ -29,5 +29,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{queryChanged:[{type:i0.Output,args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap index 95dd567dd..79d9ea018 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_property_reference.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{valueChanged:[{type:i0.Output,args:["valueChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap index c8637a0fa..c40e5080b 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_simple.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{queryChanged:[{type:i0.Output,args:["queryChanged"]}]})); })(); diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap index 52fd1b610..850cef7f4 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__output_from_observable_with_alias.snap @@ -24,5 +24,5 @@ static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({type:TestComponent,selector (() =>{ (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(TestComponent, [{type:Component,args:[{selector:"test-comp",standalone:true,template:""}]}], - null,null)); + null,{_clicked:[{type:i0.Output,args:["clicked"]}]})); })(); diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 35ffbbc06..44b1f7f70 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -2013,8 +2013,12 @@ pub fn compile_class_metadata_sync( ); // Build property decorators metadata - let prop_decorators_expr = - core_build_prop_decorators_metadata(&allocator, class, Some(&source)); + let prop_decorators_expr = core_build_prop_decorators_metadata( + &allocator, + class, + Some(&source), + &mut namespace_registry, + ); // Create R3ClassMetadata let metadata = R3ClassMetadata { From ebe132a7fee0eed90091e14fc343a73bf5d2e3ff Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 13:35:52 +0100 Subject: [PATCH 2/8] fix(class_metadata): drop unresolvable template-literal config fields from setClassMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `@Component` config field whose value is a template literal with an unresolvable `${…}` interpolation (e.g. ``selector: `${UNRESOLVED}-tag` ``) was copied verbatim into `ɵsetClassMetadata`, leaking the raw `${…}` text. The AOT `ɵcmp` path already drops such fields (#300); apply the same fold-or-drop on the `setClassMetadata` decorator-args path. Expose `resolve_template_literal` as `pub(crate)` and thread the file-scope string consts into `build_decorator_metadata_array`. Resolvable interpolations (`${KNOWN_CONST}`) still fold and are left untouched; only unresolvable fields are dropped (post-conversion, so key/quoting/ordering fidelity is preserved). Fixes the pre-existing `component_template_literal_unresolved_identifier_drops_field` test, which default-on `emit_class_metadata` (#299) had turned red. --- .../src/class_metadata/builders.rs | 63 ++++++++++++++++++- .../src/component/transform.rs | 1 + .../src/directive/decorator.rs | 2 +- .../oxc_angular_compiler/src/directive/mod.rs | 2 +- napi/angular-compiler/src/lib.rs | 1 + 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index a7f29c7f5..c24c8fa49 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -5,14 +5,15 @@ use oxc_allocator::{Allocator, Box, Vec as AllocVec}; use oxc_ast::ast::{ - Class, ClassElement, Decorator, Expression, FormalParameter, MethodDefinitionKind, PropertyKey, - TSType, TSTypeName, + Class, ClassElement, Decorator, Expression, FormalParameter, MethodDefinitionKind, + ObjectPropertyKind, PropertyKey, TSType, TSTypeName, }; use oxc_str::Ident; use crate::component::{ImportMap, NamespaceRegistry, R3DependencyMetadata}; use crate::directive::{ - R3InputMetadata, try_parse_signal_input, try_parse_signal_model, try_parse_signal_output, + R3InputMetadata, StringConsts, resolve_template_literal, try_parse_signal_input, + try_parse_signal_model, try_parse_signal_output, }; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, @@ -41,6 +42,7 @@ pub fn build_decorator_metadata_array<'a>( source_text: Option<&'a str>, inlined_template: Option<&'a str>, inlined_styles: Option<&[Ident<'a>]>, + consts: Option<&StringConsts<'a>>, ) -> OutputExpression<'a> { let mut decorator_entries = AllocVec::new_in(allocator); @@ -110,6 +112,18 @@ pub fn build_decorator_metadata_array<'a>( inlined_template, inlined_styles, ); + // Drop config fields whose value is a template literal with an + // unresolvable `${…}` interpolation, matching the AOT `ɵcmp` path + // (which drops e.g. an unresolved `selector`). Otherwise the raw + // template literal would leak verbatim into `setClassMetadata`. + if let Some(consts) = consts { + drop_unresolvable_template_literal_fields( + allocator, + &mut converted, + expr, + consts, + ); + } } args.push(converted); } @@ -241,6 +255,47 @@ fn inline_component_resources<'a>( } } +/// Remove config fields from a converted `@Component` args map when the source +/// value is a template literal whose `${…}` interpolation can't be statically +/// resolved against `consts` (e.g. `selector: \`${UNRESOLVED}-tag\``). +/// +/// Angular's partial evaluator (and OXC's AOT `ɵcmp` extraction) drops such +/// fields rather than emitting a half-evaluated literal. Resolvable template +/// literals are left untouched (converted as-is); only the unresolvable ones are +/// dropped, so the raw `${…}` text never leaks into `setClassMetadata`. +fn drop_unresolvable_template_literal_fields<'a>( + allocator: &'a Allocator, + converted: &mut OutputExpression<'a>, + source: &Expression<'a>, + consts: &StringConsts<'a>, +) { + let Expression::ObjectExpression(obj) = source else { + return; + }; + let OutputExpression::LiteralMap(map) = converted else { + return; + }; + + for property in &obj.properties { + let ObjectPropertyKind::ObjectProperty(prop) = property else { + continue; + }; + let Expression::TemplateLiteral(tpl) = &prop.value else { + continue; + }; + // An empty-interpolation template literal is a plain string — keep it. + if tpl.expressions.is_empty() { + continue; + } + if resolve_template_literal(allocator, tpl, consts).is_some() { + continue; // Resolvable — leave the converted value as-is. + } + if let Some(key) = get_property_key_name(&prop.key) { + map.entries.retain(|entry| entry.is_spread || entry.key != key); + } + } +} + /// Build a `template: "…"` map entry from the inlined content. fn build_template_entry<'a>(allocator: &'a Allocator, content: &'a str) -> LiteralMapEntry<'a> { LiteralMapEntry::new( @@ -312,6 +367,7 @@ pub fn build_ctor_params_metadata<'a>( source_text, None, None, + None, ); map_entries.push(LiteralMapEntry::new( Ident::from("decorators"), @@ -405,6 +461,7 @@ pub fn build_prop_decorators_metadata<'a>( source_text, None, None, + None, ); prop_entries.push(LiteralMapEntry::new(prop_name, decorators_array, false)); continue; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 67475d2b2..695c3ba79 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2052,6 +2052,7 @@ pub fn transform_angular_file( Some(source), Some(template), Some(metadata.styles.as_slice()), + Some(&string_consts), ), ctor_parameters: build_ctor_params_metadata( allocator, diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index f88e5a0be..13098bbb8 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -533,7 +533,7 @@ fn literal_string_from_expression<'a>(expr: &Expression<'a>) -> Option /// string consts. Returns `None` if any interpolation can't be statically /// resolved to a string — matching Angular's all-or-nothing partial evaluator /// for static metadata fields. -fn resolve_template_literal<'a>( +pub(crate) fn resolve_template_literal<'a>( allocator: &'a Allocator, tpl: &TemplateLiteral<'a>, consts: &StringConsts<'a>, diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index e5ad6f670..973fc803b 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -24,7 +24,7 @@ pub use compiler::{ DirectiveCompileResult, compile_directive, compile_directive_from_metadata, create_inputs_literal, create_outputs_literal, }; -pub(crate) use decorator::extract_string_value; +pub(crate) use decorator::{extract_string_value, resolve_template_literal}; pub use decorator::{ StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, }; diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index 44b1f7f70..e45257bab 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -1995,6 +1995,7 @@ pub fn compile_class_metadata_sync( Some(&source), None, None, + None, ); // Build constructor parameters metadata From 58e5feb21dba6bf7729514af6132b2b2a60fc44b Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:01:06 +0100 Subject: [PATCH 3/8] fix(directive): recognize as-cast/parenthesized and namespaced-required initializer APIs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match ngc's `tryParseInitializerApi`, which unwraps `as`/parenthesized expressions and resolves namespaced calls: - `x = input(0) as any` / `x = (input(0))` — unwrap `TSAsExpression`/ `ParenthesizedExpression` before matching the initializer call (applies to input/output/model/query detectors, so both the AOT ɵcmp and setClassMetadata paths recognize them). - `core.input.required()` / `core.model.required()` — recognize the namespaced `..required()` form, not just `.required()`. Output verified byte-for-byte against @angular/compiler-cli 21.2.14. --- .../src/class_metadata/builders.rs | 4 +- .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../src/directive/property_decorators.rs | 60 ++++++++++++++----- .../tests/signal_member_jit_metadata_test.rs | 50 ++++++++++++++++ 4 files changed, 98 insertions(+), 17 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index c24c8fa49..c90c28334 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -13,7 +13,7 @@ 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, + try_parse_signal_model, try_parse_signal_output, unwrap_initializer_api_expr, }; use crate::output::ast::{ ArrowFunctionBody, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapEntry, @@ -586,7 +586,7 @@ fn build_signal_query_decorator<'a>( source_text: Option<&'a str>, namespace_registry: &mut NamespaceRegistry<'a>, ) -> Option> { - let Expression::CallExpression(call) = value else { + let Expression::CallExpression(call) = unwrap_initializer_api_expr(value) else { return None; }; let decorator_name = signal_query_decorator_name(&call.callee)?; diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 973fc803b..01fa700e7 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -39,5 +39,6 @@ pub use property_decorators::{ }; pub(crate) use property_decorators::{ 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 69108da61..20ad8bf23 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -228,8 +228,8 @@ pub(crate) fn try_parse_signal_model<'a>( value: &Expression<'a>, property_name: Ident<'a>, ) -> Option> { - // Check if the value is a call expression - let call_expr = match value { + // 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, }; @@ -240,11 +240,12 @@ pub(crate) fn try_parse_signal_model<'a>( Expression::Identifier(id) if id.name == "model" => false, // model.required() - member expression call Expression::StaticMemberExpression(member) => { - // Check for model.required + // Check for model.required / core.model.required if member.property.name == "required" { - match &member.object { - Expression::Identifier(id) if id.name == "model" => true, - _ => return None, + if is_initializer_fn_reference(&member.object, "model") { + true + } else { + return None; } } else if member.property.name == "model" { // Handle namespaced calls like `core.model()` @@ -326,7 +327,7 @@ pub(crate) fn try_parse_signal_output<'a>( value: &Expression<'a>, property_name: Ident<'a>, ) -> Option<(Ident<'a>, Ident<'a>)> { - let call_expr = match value { + let call_expr = match unwrap_initializer_api_expr(value) { Expression::CallExpression(call) => call, _ => return None, }; @@ -387,13 +388,41 @@ pub(crate) fn try_parse_signal_output<'a>( /// ``` /// /// 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>, ) -> Option> { - // Check if the value is a call expression - let call_expr = match value { + // 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, }; @@ -404,11 +433,12 @@ pub(crate) fn try_parse_signal_input<'a>( Expression::Identifier(id) if id.name == "input" => false, // input.required() - member expression call Expression::StaticMemberExpression(member) => { - // Check for input.required + // Check for input.required / core.input.required if member.property.name == "required" { - match &member.object { - Expression::Identifier(id) if id.name == "input" => true, - _ => return None, + if is_initializer_fn_reference(&member.object, "input") { + true + } else { + return None; } } else if member.property.name == "input" { // Handle namespaced calls like `core.input()` @@ -870,8 +900,8 @@ fn try_parse_signal_query<'a>( property_name: Ident<'a>, source_text: Option<&'a str>, ) -> Option<(SignalQueryType, R3QueryMetadata<'a>)> { - // Check if the value is a call expression - let call_expr = match value { + // 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, }; 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 124f27009..d8dacb1b5 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 @@ -165,3 +165,53 @@ fn no_metadata_when_emit_disabled() { result.code ); } + +// ─── ngc parity: detector edge cases ─────────────────────────────────────────── + +#[test] +fn signal_input_detected_through_as_cast() { + // ngc unwraps `as` expressions when detecting initializer APIs: + // `foo = input(0) as any` is still recognized as a signal input. + let md = metadata_region(&compile(&component(" readonly value = input(0) as any;", "input"))); + assert!(md.contains("Input") && md.contains("isSignal:true"), "input behind `as` cast not detected:\n{md}"); +} + +#[test] +fn signal_input_detected_through_parentheses() { + // ngc unwraps parenthesized initializers: `foo = (input(0))`. + let md = metadata_region(&compile(&component(" readonly value = (input(0));", "input"))); + assert!(md.contains("Input") && md.contains("isSignal:true"), "parenthesized input not detected:\n{md}"); +} + +#[test] +fn namespaced_required_signal_input_detected() { + // ngc handles `core.input.required()` (namespace import + `.required()`). + let allocator = Allocator::default(); + let source = "import * as core from '@angular/core';\n\n\ + @core.Component({ selector: 'c', template: 'x', standalone: true })\n\ + export class C { readonly value = core.input.required(); }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Input") && md.contains("isSignal:true"), "core.input.required not detected:\n{md}"); + assert!(md.contains("required:true"), "required flag missing for core.input.required:\n{md}"); +} + +#[test] +fn namespaced_required_signal_model_detected() { + // ngc handles `core.model.required()` → Input(isSignal) + Output. + let allocator = Allocator::default(); + let source = "import * as core from '@angular/core';\n\n\ + @core.Component({ selector: 'c', template: 'x', standalone: true })\n\ + export class C { readonly value = core.model.required(); }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Input") && md.contains("isSignal:true"), "core.model.required input not detected:\n{md}"); + 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}"); +} From 5492f7943f91df53eafcd51636cfba05dd78e19a Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:07:36 +0100 Subject: [PATCH 4/8] feat(directive): emit setClassMetadata for directives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Directives previously emitted no `setClassMetadata`, so `TestBed.overrideDirective` could not reflect their decorators and signal inputs/outputs/queries were lost on recompile. Emit it for the `@Directive` AOT branch, mirroring the `@Component` path: the `@Directive` decorator metadata, `ctorParameters` (reflected from the class), and prop decorators (real `@Input`/`@Output`/query decorators plus synthesized initializer-API ones). Matches ngc, which emits directive class metadata. Constructor-token types are reflected from the AST (not namespace-prefixed) since directive metadata carries a different dependency representation than `build_ctor_params_metadata` consumes — the common no-constructor directive emits `null` ctorParameters, exactly like ngc. Updates two host-directive snapshots that now include the directive `setClassMetadata`. --- .../src/component/transform.rs | 56 ++++++++++++++++++- .../src/directive/decorator.rs | 4 +- .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../tests/signal_member_jit_metadata_test.rs | 45 +++++++++++++++ ...st__host_directives_with_host_aliases.snap | 6 ++ ...__host_directives_with_inputs_outputs.snap | 5 ++ 6 files changed, 114 insertions(+), 3 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 695c3ba79..49a51d11f 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -40,7 +40,7 @@ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, extract_content_queries, extract_directive_metadata, extract_view_queries, - find_directive_decorator_span, generate_directive_definitions, + find_directive_decorator, find_directive_decorator_span, generate_directive_definitions, }; use crate::dts; use crate::injectable::{ @@ -2235,13 +2235,65 @@ pub fn transform_angular_file( .dts_declarations .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); + // Emit setClassMetadata for TestBed support (overrideDirective + signal + // members), mirroring the @Component path. Matches ngc, which emits + // directive class metadata including synthesized signal-member prop + // decorators. + let mut decls_after_class = String::new(); + if options.emit_class_metadata + && !options.advanced_optimizations + && let Some(decorator) = find_directive_decorator(&class.decorators) + { + let type_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { + name: Ident::from(class_name.as_str()), + source_span: None, + }, + allocator, + )); + let class_metadata = R3ClassMetadata { + r#type: type_expr, + decorators: build_decorator_metadata_array( + allocator, + &[decorator], + Some(source), + None, + None, + Some(&string_consts), + ), + // Constructor deps are reflected from the class AST. + // Directive metadata carries a different dependency + // representation than `build_ctor_params_metadata` consumes, + // so imported ctor-token types are not namespace-prefixed here + // (matches the standalone-NAPI path; the common no-ctor case + // emits `null`, exactly like ngc). + ctor_parameters: build_ctor_params_metadata( + allocator, + class, + None, + &mut file_namespace_registry, + &import_map, + Some(source), + ), + prop_decorators: build_prop_decorators_metadata( + allocator, + class, + Some(source), + &mut file_namespace_registry, + ), + }; + let metadata_expr = compile_class_metadata(allocator, &class_metadata); + decls_after_class.push_str(&emitter.emit_expression(&metadata_expr)); + decls_after_class.push(';'); + } + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); class_definitions - .insert(class_name, (property_assignments, String::new(), String::new())); + .insert(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)) { diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 13098bbb8..6403500a9 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -22,7 +22,9 @@ use crate::output::ast::{OutputAstBuilder, OutputExpression, ReadVarExpr}; use crate::output::oxc_converter::convert_oxc_expression; /// Find the @Directive decorator in a list of decorators. -fn find_directive_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_directive_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_directive_call(&call.callee), Expression::Identifier(id) => id.name == "Directive", diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 01fa700e7..78cffa044 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -28,6 +28,7 @@ pub(crate) use decorator::{extract_string_value, resolve_template_literal}; pub use decorator::{ StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, }; +pub(crate) use decorator::find_directive_decorator; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, 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 d8dacb1b5..0f4d62cff 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 @@ -199,6 +199,51 @@ fn namespaced_required_signal_input_detected() { assert!(md.contains("required:true"), "required flag missing for core.input.required:\n{md}"); } +#[test] +fn directive_emits_set_class_metadata_with_signal_input() { + // ngc emits setClassMetadata for @Directive too (incl. signal-member prop + // decorators), so signal inputs survive TestBed.overrideDirective. + let allocator = Allocator::default(); + let source = "import { Directive, input, Input } from '@angular/core';\n\n\ + @Directive({ selector: '[appFoo]', standalone: true })\n\ + export class FooDirective {\n\ + readonly value = input('x');\n\ + @Input() classic = 0;\n\ + }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + assert!( + result.code.contains("\u{275}setClassMetadata"), + "directive should emit setClassMetadata:\n{}", + result.code + ); + let md = metadata_region(&result.code); + assert!(md.contains("Directive"), "Directive decorator metadata missing:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "signal input prop decorator missing for directive:\n{md}" + ); + assert!(md.contains("classic"), "classic @Input prop decorator missing:\n{md}"); +} + +#[test] +fn directive_no_metadata_when_emit_disabled() { + let allocator = Allocator::default(); + let source = "import { Directive, input } from '@angular/core';\n\n\ + @Directive({ selector: '[appFoo]', standalone: true })\n\ + export class FooDirective { readonly value = input('x'); }\n"; + let options = TransformOptions { emit_class_metadata: false, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", source, Some(&options), None); + assert!( + !result.code.contains("\u{275}setClassMetadata"), + "no setClassMetadata when disabled:\n{}", + result.code + ); +} + #[test] fn namespaced_required_signal_model_detected() { // ngc handles `core.model.required()` → Input(isSignal) + Output. diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap index a3532cc06..eee3478e1 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_host_aliases.snap @@ -18,6 +18,12 @@ static ɵfac = function HostDir_Factory(__ngFactoryType__) { static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:[0,"valueAlias","value"], color:[0,"colorAlias","color"]},outputs:{opened:"openedAlias",closed:"closedAlias"}}); } +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(HostDir, + [{type:Directive,args:[{}]}],null,{value:[{type:Input,args:["valueAlias"]}], + color:[{type:Input,args:["colorAlias"]}],opened:[{type:Output,args:["openedAlias"]}], + closed:[{type:Output,args:["closedAlias"]}]})); +})(); export class MyComponent { diff --git a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap index f9eee3784..a44fe3308 100644 --- a/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap +++ b/crates/oxc_angular_compiler/tests/snapshots/integration_test__host_directives_with_inputs_outputs.snap @@ -18,6 +18,11 @@ static ɵfac = function HostDir_Factory(__ngFactoryType__) { static ɵdir = /*@__PURE__*/ i0.ɵɵdefineDirective({type:HostDir,inputs:{value:"value",color:"color"}, outputs:{opened:"opened",closed:"closed"}}); } +(() =>{ + (((typeof ngDevMode === "undefined") || ngDevMode) && i0.ɵsetClassMetadata(HostDir, + [{type:Directive,args:[{}]}],null,{value:[{type:Input}],color:[{type:Input}], + opened:[{type:Output}],closed:[{type:Output}]})); +})(); export class MyComponent { From 47c1ee3ecbd6faf0c6765cbde9fab6caf0686679 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:19:08 +0100 Subject: [PATCH 5/8] feat(class_metadata): emit setClassMetadata for pipes, injectables, and NgModules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends directive metadata support to the remaining decorated class kinds so TestBed.overridePipe/overrideModule and reflection work, matching ngc which emits setClassMetadata for every decorated class. Each emits the decorator metadata, ctorParameters (reflected from the class — imported token types are namespace- prefixed via the import map, e.g. `() => [{ type: i0.Injector }]`), and prop decorators. Extracts the shared `build_set_class_metadata_decls` helper (used by the @Directive/@Pipe/@Injectable/@NgModule branches), replacing the inline directive block. Output verified byte-for-byte against @angular/compiler-cli 21.2.14 for each kind, including constructor-DI cases. --- .../src/component/transform.rs | 187 +++++++++++++----- .../src/injectable/decorator.rs | 7 + .../src/injectable/mod.rs | 1 + .../src/ng_module/decorator.rs | 4 +- .../oxc_angular_compiler/src/ng_module/mod.rs | 1 + .../src/pipe/decorator.rs | 4 +- crates/oxc_angular_compiler/src/pipe/mod.rs | 1 + .../tests/signal_member_jit_metadata_test.rs | 55 ++++++ 8 files changed, 206 insertions(+), 54 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 49a51d11f..066326159 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -44,11 +44,12 @@ use crate::directive::{ }; use crate::dts; use crate::injectable::{ - extract_injectable_metadata, find_injectable_decorator_span, + extract_injectable_metadata, find_injectable_decorator, find_injectable_decorator_span, generate_injectable_definition_from_decorator, }; use crate::ng_module::{ - extract_ng_module_metadata, find_ng_module_decorator_span, generate_full_ng_module_definition, + extract_ng_module_metadata, find_ng_module_decorator, find_ng_module_decorator_span, + generate_full_ng_module_definition, }; use crate::output::ast::{ DeclareFunctionStmt, FunctionExpr, OutputExpression, OutputStatement, ReadPropExpr, @@ -59,7 +60,8 @@ 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_span, generate_full_pipe_definition_from_decorator, + extract_pipe_metadata, find_pipe_decorator, find_pipe_decorator_span, + generate_full_pipe_definition_from_decorator, }; use crate::pipeline::compilation::{DeferBlockDepsEmitMode, TemplateCompilationMode}; use crate::pipeline::emit::{ @@ -672,6 +674,65 @@ fn find_last_import_end(program_body: &[Statement<'_>]) -> Option { last_import_end.map(|pos| pos as usize) } +/// Build the `ɵsetClassMetadata(...)` declaration string for a non-`@Component` +/// decorated class (`@Directive`/`@Pipe`/`@Injectable`/`@NgModule`). +/// +/// Mirrors the `@Component` metadata block (without template/style inlining): +/// emits the decorator metadata, `ctorParameters` (reflected from the class, with +/// imported token types namespace-prefixed via the import map), and prop decorators +/// (real `@Input`/`@Output`/query plus synthesized initializer-API). Returns an +/// empty string when metadata emission is disabled. Matches ngc, which emits +/// `setClassMetadata` for all decorated classes (needed for TestBed overrides). +#[allow(clippy::too_many_arguments)] +fn build_set_class_metadata_decls<'a>( + allocator: &'a Allocator, + class: &oxc_ast::ast::Class<'a>, + class_name: &str, + decorator: &oxc_ast::ast::Decorator<'a>, + options: &TransformOptions, + source: &'a str, + string_consts: &crate::directive::StringConsts<'a>, + import_map: &ImportMap<'a>, + namespace_registry: &mut NamespaceRegistry<'a>, +) -> String { + if !options.emit_class_metadata || options.advanced_optimizations { + return String::new(); + } + + let type_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( + ReadVarExpr { name: Ident::from(class_name), source_span: None }, + allocator, + )); + let class_metadata = R3ClassMetadata { + r#type: type_expr, + decorators: build_decorator_metadata_array( + allocator, + &[decorator], + Some(source), + None, + None, + Some(string_consts), + ), + ctor_parameters: build_ctor_params_metadata( + allocator, + class, + None, + namespace_registry, + import_map, + Some(source), + ), + prop_decorators: build_prop_decorators_metadata( + allocator, + class, + Some(source), + namespace_registry, + ), + }; + let metadata_expr = compile_class_metadata(allocator, &class_metadata); + let emitter = JsEmitter::new(); + format!("{};", emitter.emit_expression(&metadata_expr)) +} + // ============================================================================ // JIT Compilation Transform // ============================================================================ @@ -2235,57 +2296,23 @@ pub fn transform_angular_file( .dts_declarations .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); - // Emit setClassMetadata for TestBed support (overrideDirective + signal - // members), mirroring the @Component path. Matches ngc, which emits - // directive class metadata including synthesized signal-member prop - // decorators. - let mut decls_after_class = String::new(); - if options.emit_class_metadata - && !options.advanced_optimizations - && let Some(decorator) = find_directive_decorator(&class.decorators) - { - let type_expr = OutputExpression::ReadVar(oxc_allocator::Box::new_in( - ReadVarExpr { - name: Ident::from(class_name.as_str()), - source_span: None, - }, - allocator, - )); - let class_metadata = R3ClassMetadata { - r#type: type_expr, - decorators: build_decorator_metadata_array( - allocator, - &[decorator], - Some(source), - None, - None, - Some(&string_consts), - ), - // Constructor deps are reflected from the class AST. - // Directive metadata carries a different dependency - // representation than `build_ctor_params_metadata` consumes, - // so imported ctor-token types are not namespace-prefixed here - // (matches the standalone-NAPI path; the common no-ctor case - // emits `null`, exactly like ngc). - ctor_parameters: build_ctor_params_metadata( + // 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, - None, - &mut file_namespace_registry, + &class_name, + decorator, + options, + source, + &string_consts, &import_map, - Some(source), - ), - prop_decorators: build_prop_decorators_metadata( - allocator, - class, - Some(source), &mut file_namespace_registry, - ), - }; - let metadata_expr = compile_class_metadata(allocator, &class_metadata); - decls_after_class.push_str(&emitter.emit_expression(&metadata_expr)); - decls_after_class.push(';'); - } + ) + }) + .unwrap_or_default(); class_positions.push(( class_name.clone(), @@ -2363,6 +2390,23 @@ pub fn transform_angular_file( has_injectable, )); + // 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(); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -2370,7 +2414,7 @@ pub fn transform_angular_file( )); class_definitions.insert( class_name, - (property_assignments, String::new(), String::new()), + (property_assignments, String::new(), decls_after_class), ); } } else if let Some(mut ng_module_metadata) = @@ -2454,6 +2498,28 @@ pub fn transform_angular_file( has_injectable, )); + // Emit setClassMetadata for TestBed support (overrideModule), + // appended after the NgModule's external declarations. + if let Some(decorator) = find_ng_module_decorator(&class.decorators) { + let metadata = build_set_class_metadata_decls( + allocator, + class, + &class_name, + decorator, + options, + source, + &string_consts, + &import_map, + &mut file_namespace_registry, + ); + if !metadata.is_empty() { + if !external_decls.is_empty() { + external_decls.push('\n'); + } + external_decls.push_str(&metadata); + } + } + // NgModule: external_decls go AFTER the class (they reference the class name) class_positions.push(( class_name.clone(), @@ -2513,6 +2579,23 @@ pub fn transform_angular_file( type_argument_count, )); + // 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(); + class_positions.push(( class_name.clone(), compute_effective_start(class, &decorator_spans_to_remove, stmt_start), @@ -2520,7 +2603,7 @@ pub fn transform_angular_file( )); class_definitions.insert( class_name, - (property_assignments, String::new(), String::new()), + (property_assignments, String::new(), decls_after_class), ); } } diff --git a/crates/oxc_angular_compiler/src/injectable/decorator.rs b/crates/oxc_angular_compiler/src/injectable/decorator.rs index 0d0ab178e..61ed3cf5f 100644 --- a/crates/oxc_angular_compiler/src/injectable/decorator.rs +++ b/crates/oxc_angular_compiler/src/injectable/decorator.rs @@ -204,6 +204,13 @@ fn convert_deps_to_r3<'a>( result } +/// Find the `@Injectable` decorator node on a class. +pub(crate) fn find_injectable_decorator<'a>( + decorators: &'a [oxc_ast::ast::Decorator<'a>], +) -> Option<&'a oxc_ast::ast::Decorator<'a>> { + decorators.iter().find(|d| is_injectable_decorator(d)) +} + /// Find the span of the `@Injectable` decorator on a class. pub fn find_injectable_decorator_span(class: &Class<'_>) -> Option { for decorator in &class.decorators { diff --git a/crates/oxc_angular_compiler/src/injectable/mod.rs b/crates/oxc_angular_compiler/src/injectable/mod.rs index c1ad3557a..77ee8ddb2 100644 --- a/crates/oxc_angular_compiler/src/injectable/mod.rs +++ b/crates/oxc_angular_compiler/src/injectable/mod.rs @@ -21,6 +21,7 @@ pub use decorator::{ DependencyMetadata, InjectableMetadata, ProvidedInValue, UseClassMetadata, UseExistingMetadata, UseFactoryMetadata, extract_injectable_metadata, find_injectable_decorator_span, }; +pub(crate) use decorator::find_injectable_decorator; pub use definition::{ InjectableDefinition, generate_injectable_definition, generate_injectable_definition_from_decorator, diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index aae2f05b8..7ede04e94 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -280,7 +280,9 @@ pub fn extract_ng_module_metadata<'a>( } /// Find the @NgModule decorator in a list of decorators. -fn find_ng_module_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_ng_module_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_ng_module_call(&call.callee), Expression::Identifier(id) => id.name == "NgModule", diff --git a/crates/oxc_angular_compiler/src/ng_module/mod.rs b/crates/oxc_angular_compiler/src/ng_module/mod.rs index 8666185bc..e44d66180 100644 --- a/crates/oxc_angular_compiler/src/ng_module/mod.rs +++ b/crates/oxc_angular_compiler/src/ng_module/mod.rs @@ -18,6 +18,7 @@ mod metadata; pub use compiler::{NgModuleCompileResult, compile_ng_module, compile_ng_module_from_metadata}; pub use decorator::{NgModuleMetadata, extract_ng_module_metadata, find_ng_module_decorator_span}; +pub(crate) use decorator::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 288533cb1..b1a359043 100644 --- a/crates/oxc_angular_compiler/src/pipe/decorator.rs +++ b/crates/oxc_angular_compiler/src/pipe/decorator.rs @@ -174,7 +174,9 @@ pub fn extract_pipe_metadata<'a>( } /// Find the @Pipe decorator in a list of decorators. -fn find_pipe_decorator<'a>(decorators: &'a [Decorator<'a>]) -> Option<&'a Decorator<'a>> { +pub(crate) fn find_pipe_decorator<'a>( + decorators: &'a [Decorator<'a>], +) -> Option<&'a Decorator<'a>> { decorators.iter().find(|d| match &d.expression { Expression::CallExpression(call) => is_pipe_call(&call.callee), Expression::Identifier(id) => id.name == "Pipe", diff --git a/crates/oxc_angular_compiler/src/pipe/mod.rs b/crates/oxc_angular_compiler/src/pipe/mod.rs index 82fc05f60..ad0e62422 100644 --- a/crates/oxc_angular_compiler/src/pipe/mod.rs +++ b/crates/oxc_angular_compiler/src/pipe/mod.rs @@ -17,6 +17,7 @@ mod metadata; pub use compiler::{PipeCompileResult, compile_pipe, compile_pipe_from_metadata}; pub use decorator::{PipeMetadata, extract_pipe_metadata, find_pipe_decorator_span}; +pub(crate) use decorator::find_pipe_decorator; 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/signal_member_jit_metadata_test.rs b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs index 0f4d62cff..8cad8d768 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 @@ -228,6 +228,61 @@ fn directive_emits_set_class_metadata_with_signal_input() { assert!(md.contains("classic"), "classic @Input prop decorator missing:\n{md}"); } +#[test] +fn pipe_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { Pipe } from '@angular/core';\n\n\ + @Pipe({ name: 'foo', standalone: true })\n\ + export class FooPipe { transform(v: unknown) { return v; } }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.pipe.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Pipe"), "Pipe decorator metadata missing:\n{md}"); + assert!(md.contains("name:\"foo\""), "Pipe name arg missing:\n{md}"); +} + +#[test] +fn injectable_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { Injectable } from '@angular/core';\n\n\ + @Injectable({ providedIn: 'root' })\n\ + export class FooService {}\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.service.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("Injectable"), "Injectable decorator metadata missing:\n{md}"); +} + +#[test] +fn injectable_with_ctor_di_emits_namespaced_ctor_parameters() { + // ctorParameters must namespace-prefix imported token types (e.g. i0.Injector), + // matching ngc — verified the import_map path handles this without explicit deps. + let allocator = Allocator::default(); + let source = "import { Injectable, Injector } from '@angular/core';\n\n\ + @Injectable()\n\ + export class FooService { constructor(private inj: Injector) {} }\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.service.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains(".Injector"), "ctorParameters should namespace-prefix Injector:\n{md}"); +} + +#[test] +fn ng_module_emits_set_class_metadata() { + let allocator = Allocator::default(); + let source = "import { NgModule } from '@angular/core';\n\n\ + @NgModule({ declarations: [], imports: [] })\n\ + export class FooModule {}\n"; + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = transform_angular_file(&allocator, "foo.module.ts", source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + let md = metadata_region(&result.code); + assert!(md.contains("NgModule"), "NgModule decorator metadata missing:\n{md}"); +} + #[test] fn directive_no_metadata_when_emit_disabled() { let allocator = Allocator::default(); From eb4a5455dfc09e366c75676c896eac43fd11347c Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:28:09 +0100 Subject: [PATCH 6/8] test(class_metadata): cover full classic decorator set in setClassMetadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add regression guards for the classic (non-signal) decorators in setClassMetadata, on both @Component and @Directive: plain/aliased/config @Input, plain/aliased @Output, @ViewChild/@ViewChildren, @ContentChild/@ContentChildren (with read/ descendants), @HostBinding, and @HostListener (with args) — plus a mixed classic+signal class. Expected shapes verified byte-for-byte against @angular/compiler-cli 21.2.14. --- .../tests/signal_member_jit_metadata_test.rs | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) 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 8cad8d768..f81178958 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 @@ -283,6 +283,97 @@ fn ng_module_emits_set_class_metadata() { assert!(md.contains("NgModule"), "NgModule decorator metadata missing:\n{md}"); } +// ─── classic decorator coverage (regression guards) ──────────────────────────── + +/// Full classic decorator set on a component — verified byte-for-byte against ngc. +const CLASSIC_MEMBERS: &str = "\ + @Input() a = 0;\n\ + @Input('aliasB') b = 0;\n\ + @Input({ alias: 'aliasC', required: true }) c = 0;\n\ + @Output() d = new EventEmitter();\n\ + @Output('aliasE') e = new EventEmitter();\n\ + @ViewChild('ref') vc: unknown;\n\ + @ViewChildren('refs') vcs: unknown;\n\ + @ContentChild('cc', { read: ElementRef }) cc: unknown;\n\ + @ContentChildren('ccs', { descendants: true }) ccs: unknown;\n\ + @HostBinding('class.active') active = true;\n\ + @HostListener('click', ['$event']) onClick(_e: unknown) {}"; + +const CLASSIC_IMPORTS: &str = + "Input, Output, EventEmitter, ViewChild, ViewChildren, ContentChild, ContentChildren, \ + HostBinding, HostListener, ElementRef"; + +fn assert_classic_decorators(raw: &str) { + // The emitter wraps long setClassMetadata across lines; strip whitespace so the + // assertions match regardless of wrapping (no string literal here contains spaces). + let md: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let md = md.as_str(); + assert!(md.contains("a:[{type:Input}]"), "plain @Input:\n{md}"); + assert!(md.contains("b:[{type:Input,args:[\"aliasB\"]}]"), "aliased @Input:\n{md}"); + assert!( + md.contains("c:[{type:Input,args:[{alias:\"aliasC\",required:true}]}]"), + "@Input config object:\n{md}" + ); + assert!(md.contains("d:[{type:Output}]"), "plain @Output:\n{md}"); + assert!(md.contains("e:[{type:Output,args:[\"aliasE\"]}]"), "aliased @Output:\n{md}"); + assert!(md.contains("vc:[{type:ViewChild,args:[\"ref\"]}]"), "@ViewChild:\n{md}"); + assert!(md.contains("vcs:[{type:ViewChildren,args:[\"refs\"]}]"), "@ViewChildren:\n{md}"); + assert!( + md.contains("cc:[{type:ContentChild,args:[\"cc\",{read:ElementRef}]}]"), + "@ContentChild with read:\n{md}" + ); + assert!( + md.contains("ccs:[{type:ContentChildren,args:[\"ccs\",{descendants:true}]}]"), + "@ContentChildren with descendants:\n{md}" + ); + assert!( + md.contains("active:[{type:HostBinding,args:[\"class.active\"]}]"), + "@HostBinding:\n{md}" + ); + assert!( + md.contains("onClick:[{type:HostListener,args:[\"click\",[\"$event\"]]}]"), + "@HostListener with args:\n{md}" + ); + // Classic decorators reference the bare imported symbol, not `i0.Input`. + assert!(!md.contains("isSignal"), "classic decorators must not carry isSignal:\n{md}"); +} + +#[test] +fn component_classic_decorators_match_ngc() { + let md = metadata_region(&compile(&component(CLASSIC_MEMBERS, CLASSIC_IMPORTS))); + assert_classic_decorators(&md); +} + +#[test] +fn directive_classic_decorators_match_ngc() { + let allocator = Allocator::default(); + let source = format!( + "import {{ Directive, {CLASSIC_IMPORTS} }} from '@angular/core';\n\n\ + @Directive({{ selector: '[appFoo]' }})\n\ + export class FooDirective {{\n{CLASSIC_MEMBERS}\n}}\n" + ); + let options = TransformOptions { emit_class_metadata: true, ..TransformOptions::default() }; + let result = + transform_angular_file(&allocator, "foo.directive.ts", &source, Some(&options), None); + assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); + assert_classic_decorators(&metadata_region(&result.code)); +} + +#[test] +fn mixed_classic_and_signal_members_coexist() { + let raw = metadata_region(&compile(&component( + " @Input() classic = 0;\n readonly sig = input('x');\n readonly sigOut = output();\n readonly vc = viewChild('ref');", + "Input, input, output, viewChild", + ))); + let md: String = raw.chars().filter(|c| !c.is_whitespace()).collect(); + let md = md.as_str(); + // Classic input emitted bare; signal members synthesized with isSignal. + assert!(md.contains("classic:[{type:Input}]"), "classic @Input:\n{md}"); + assert!(md.contains("sig:[{type:i0.Input,args:[{isSignal:true"), "signal input:\n{md}"); + assert!(md.contains("sigOut:[{type:i0.Output"), "signal output:\n{md}"); + assert!(md.contains("vc:[{type:i0.ViewChild,args:[\"ref\",{isSignal:true}]}]"), "signal query:\n{md}"); +} + #[test] fn directive_no_metadata_when_emit_disabled() { let allocator = Allocator::default(); From 35fc25d5c3f8d4ef033d3c115ab0817613e1b923 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:34:04 +0100 Subject: [PATCH 7/8] refactor(class_metadata): require a locator when synthesizing query decorators A signal query with no locator (`viewChild()`) is invalid (ngc errors); skip synthesis instead of emitting a malformed decorator that treats the options object as the predicate. No behavior change for valid queries. Also drops a stale duplicate comment. --- .../src/class_metadata/builders.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index c90c28334..4c9854a35 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -591,16 +591,14 @@ fn build_signal_query_decorator<'a>( }; 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 + // decorator. + let predicate = + convert_oxc_expression(allocator, call.arguments.first()?.to_expression(), source_text)?; let mut args = AllocVec::new_in(allocator); + args.push(predicate); - // Predicate: the first positional argument, reused as-is. - if let Some(first) = call.arguments.first() - && let Some(predicate) = convert_oxc_expression(allocator, first.to_expression(), source_text) - { - args.push(predicate); - } - - // Options: merge the source options object (if any) with `isSignal: true`. // Options: `{ ..., isSignal: true }`. Spread the second positional // argument verbatim (matching Angular's `factory.createSpreadAssignment(callArgs[1])`), // which preserves any options expression, object literal or not. From 53a2d9c55d86499d0a0b91c06c4b8073716085e3 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Wed, 27 May 2026 14:39:04 +0100 Subject: [PATCH 8/8] style: cargo fmt --- .../src/class_metadata/builders.rs | 16 +++++++-- .../src/component/transform.rs | 6 ++-- .../oxc_angular_compiler/src/directive/mod.rs | 4 +-- .../src/directive/property_decorators.rs | 4 +-- .../src/injectable/mod.rs | 2 +- .../oxc_angular_compiler/src/ng_module/mod.rs | 2 +- crates/oxc_angular_compiler/src/pipe/mod.rs | 2 +- .../tests/signal_member_jit_metadata_test.rs | 36 ++++++++++++------- 8 files changed, 47 insertions(+), 25 deletions(-) diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index 4c9854a35..233563a27 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -556,7 +556,11 @@ fn build_signal_input_decorator<'a>( input: &R3InputMetadata<'a>, ) -> OutputExpression<'a> { let mut config = AllocVec::new_in(allocator); - config.push(LiteralMapEntry::new(Ident::from("isSignal"), bool_literal(allocator, true), false)); + config.push(LiteralMapEntry::new( + Ident::from("isSignal"), + bool_literal(allocator, true), + false, + )); config.push(LiteralMapEntry::new( Ident::from("alias"), string_literal(allocator, input.binding_property_name.clone()), @@ -609,7 +613,11 @@ fn build_signal_query_decorator<'a>( { options.push(LiteralMapEntry::spread(source_options)); } - options.push(LiteralMapEntry::new(Ident::from("isSignal"), bool_literal(allocator, true), false)); + options.push(LiteralMapEntry::new( + Ident::from("isSignal"), + bool_literal(allocator, true), + false, + )); args.push(OutputExpression::LiteralMap(Box::new_in( LiteralMapExpr { entries: options, source_span: None }, allocator, @@ -638,7 +646,9 @@ fn signal_query_decorator_name(callee: &Expression<'_>) -> Option<&'static str> 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()), + Expression::StaticMemberExpression(inner) => { + name_of(inner.property.name.as_str()) + } _ => None, } } else { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 066326159..51c21656d 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2319,8 +2319,10 @@ pub fn transform_angular_file( compute_effective_start(class, &decorator_spans_to_remove, stmt_start), class.body.span.end, )); - class_definitions - .insert(class_name, (property_assignments, String::new(), decls_after_class)); + class_definitions.insert( + 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)) { diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 78cffa044..279208720 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -24,11 +24,11 @@ pub use compiler::{ DirectiveCompileResult, compile_directive, compile_directive_from_metadata, create_inputs_literal, create_outputs_literal, }; -pub(crate) use decorator::{extract_string_value, resolve_template_literal}; +pub(crate) use decorator::find_directive_decorator; pub use decorator::{ StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, }; -pub(crate) use decorator::find_directive_decorator; +pub(crate) use decorator::{extract_string_value, resolve_template_literal}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index 20ad8bf23..738e185a7 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -392,9 +392,7 @@ pub(crate) fn try_parse_signal_output<'a>( /// 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> { +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), diff --git a/crates/oxc_angular_compiler/src/injectable/mod.rs b/crates/oxc_angular_compiler/src/injectable/mod.rs index 77ee8ddb2..dd710e6b1 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::find_injectable_decorator; pub use definition::{ InjectableDefinition, generate_injectable_definition, generate_injectable_definition_from_decorator, diff --git a/crates/oxc_angular_compiler/src/ng_module/mod.rs b/crates/oxc_angular_compiler/src/ng_module/mod.rs index e44d66180..8cea9511d 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 use decorator::{NgModuleMetadata, extract_ng_module_metadata, find_ng_module_decorator_span}; pub(crate) use decorator::find_ng_module_decorator; +pub use decorator::{NgModuleMetadata, extract_ng_module_metadata, find_ng_module_decorator_span}; 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/mod.rs b/crates/oxc_angular_compiler/src/pipe/mod.rs index ad0e62422..4f91fc7d9 100644 --- a/crates/oxc_angular_compiler/src/pipe/mod.rs +++ b/crates/oxc_angular_compiler/src/pipe/mod.rs @@ -16,8 +16,8 @@ mod definition; mod metadata; pub use compiler::{PipeCompileResult, compile_pipe, compile_pipe_from_metadata}; -pub use decorator::{PipeMetadata, extract_pipe_metadata, find_pipe_decorator_span}; pub(crate) use decorator::find_pipe_decorator; +pub use decorator::{PipeMetadata, extract_pipe_metadata, 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/signal_member_jit_metadata_test.rs b/crates/oxc_angular_compiler/tests/signal_member_jit_metadata_test.rs index f81178958..c2f8afc54 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 @@ -100,7 +100,8 @@ fn aliased_signal_input_uses_alias() { #[test] fn signal_output_emits_output_prop_decorator() { - let md = metadata_region(&compile(&component(" readonly changed = output();", "output"))); + let md = + metadata_region(&compile(&component(" readonly changed = output();", "output"))); assert!(md.contains("changed"), "prop key missing:\n{md}"); assert!(md.contains("Output"), "synthetic Output decorator missing:\n{md}"); // output() lowers to `Output("")` (a single string arg). @@ -145,10 +146,7 @@ fn classic_input_output_unchanged_and_not_signal() { assert!(md.contains("foo"), "classic @Input prop key missing:\n{md}"); assert!(md.contains("bar"), "classic @Output prop key missing:\n{md}"); assert!(md.contains("type:Input"), "classic Input type missing:\n{md}"); - assert!( - !md.contains("isSignal"), - "classic decorators must not gain an isSignal flag:\n{md}" - ); + assert!(!md.contains("isSignal"), "classic decorators must not gain an isSignal flag:\n{md}"); } #[test] @@ -173,14 +171,20 @@ fn signal_input_detected_through_as_cast() { // ngc unwraps `as` expressions when detecting initializer APIs: // `foo = input(0) as any` is still recognized as a signal input. let md = metadata_region(&compile(&component(" readonly value = input(0) as any;", "input"))); - assert!(md.contains("Input") && md.contains("isSignal:true"), "input behind `as` cast not detected:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "input behind `as` cast not detected:\n{md}" + ); } #[test] fn signal_input_detected_through_parentheses() { // ngc unwraps parenthesized initializers: `foo = (input(0))`. let md = metadata_region(&compile(&component(" readonly value = (input(0));", "input"))); - assert!(md.contains("Input") && md.contains("isSignal:true"), "parenthesized input not detected:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "parenthesized input not detected:\n{md}" + ); } #[test] @@ -195,7 +199,10 @@ fn namespaced_required_signal_input_detected() { transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); let md = metadata_region(&result.code); - assert!(md.contains("Input") && md.contains("isSignal:true"), "core.input.required not detected:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "core.input.required not detected:\n{md}" + ); assert!(md.contains("required:true"), "required flag missing for core.input.required:\n{md}"); } @@ -299,8 +306,7 @@ const CLASSIC_MEMBERS: &str = "\ @HostBinding('class.active') active = true;\n\ @HostListener('click', ['$event']) onClick(_e: unknown) {}"; -const CLASSIC_IMPORTS: &str = - "Input, Output, EventEmitter, ViewChild, ViewChildren, ContentChild, ContentChildren, \ +const CLASSIC_IMPORTS: &str = "Input, Output, EventEmitter, ViewChild, ViewChildren, ContentChild, ContentChildren, \ HostBinding, HostListener, ElementRef"; fn assert_classic_decorators(raw: &str) { @@ -371,7 +377,10 @@ fn mixed_classic_and_signal_members_coexist() { assert!(md.contains("classic:[{type:Input}]"), "classic @Input:\n{md}"); assert!(md.contains("sig:[{type:i0.Input,args:[{isSignal:true"), "signal input:\n{md}"); assert!(md.contains("sigOut:[{type:i0.Output"), "signal output:\n{md}"); - assert!(md.contains("vc:[{type:i0.ViewChild,args:[\"ref\",{isSignal:true}]}]"), "signal query:\n{md}"); + assert!( + md.contains("vc:[{type:i0.ViewChild,args:[\"ref\",{isSignal:true}]}]"), + "signal query:\n{md}" + ); } #[test] @@ -402,7 +411,10 @@ fn namespaced_required_signal_model_detected() { transform_angular_file(&allocator, "test.component.ts", source, Some(&options), None); assert!(!result.has_errors(), "compile errored: {:?}", result.diagnostics); let md = metadata_region(&result.code); - assert!(md.contains("Input") && md.contains("isSignal:true"), "core.model.required input not detected:\n{md}"); + assert!( + md.contains("Input") && md.contains("isSignal:true"), + "core.model.required input not detected:\n{md}" + ); 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}"); }