From 6f76a210f4cb3a9c0f7b18696a9235d2716a2b93 Mon Sep 17 00:00:00 2001 From: Ashley Hunter Date: Thu, 24 Sep 2026 08:09:03 +0100 Subject: [PATCH 1/7] feat(directive): validate input transforms like ngtsc ngtsc rejects an input `transform` it can't prove is a usable function (`parseDecoratorInputTransformFunction`); oxc accepted anything, so code that fails to build with ngc compiled here. Both `@Input({ transform })` and `inputs: [{ transform }]` are now checked, with ngtsc's diagnostics word for word and in its order (`inputs:` entries, then members): - not a function: literals, classes, calls, and function expressions reached through a variable, property access or parentheses (only one written directly as the property value is analyzable) - generic, or overloaded (more than one call signature) - first parameter untyped or a spread - first parameter typed with a same-file type that isn't exported - a `static ngAcceptInputType_` member on the class The evaluator learns functions to do this: same-file function declarations and static methods resolve to their definitions. A transform reached through such a reference (`T.f` where `const T = { f }`) is emitted as the function's own name, as ngtsc does; a static method keeps the written expression, since ngtsc's bare method name isn't in scope and would bind an unrelated same-named function. `@Input` members are also found through a namespace import (`@core.Input`), like `@core.Component` already was. Imported transforms can't be inspected from one file and are assumed to be functions. --- .../src/component/decorator.rs | 4 +- .../src/directive/decorator.rs | 156 +++- .../src/directive/evaluator.rs | 305 +++++++- .../oxc_angular_compiler/src/directive/mod.rs | 4 +- .../src/directive/property_decorators.rs | 22 +- .../tests/decorator_metadata_ngtsc_test.rs | 35 +- .../fixtures/decorator_metadata_ngtsc.json | 731 ++++++++++++++++++ 7 files changed, 1193 insertions(+), 64 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 604dad1c9..a9ec45c00 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -20,6 +20,7 @@ use super::transform::ImportMap; use crate::directive::{ StringConsts, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, merge_by_class_property, parse_decorator_io, + resolve_member_transforms, }; use crate::output::oxc_converter::convert_oxc_expression; @@ -263,12 +264,13 @@ pub fn extract_component_metadata<'a>( // Inputs/outputs from the `inputs:`/`outputs:` metadata, overridden by // @Input/@Output/signal members ({...fromMeta, ...fromFields} in ngtsc). - let io = parse_decorator_io(allocator, config_obj, source_text, consts); + let io = parse_decorator_io(allocator, config_obj, class, source_text, consts); metadata.inputs = merge_by_class_property( io.inputs, extract_input_metadata(allocator, class, source_text), |i| i.class_property_name.as_str(), ); + resolve_member_transforms(allocator, class, source_text, consts, &mut metadata.inputs); metadata.outputs = merge_by_class_property(io.outputs, extract_output_metadata(allocator, class), |o| { o.0.as_str() diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 75fe699da..aa75e492f 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -15,7 +15,7 @@ use oxc_diagnostics::OxcDiagnostic; use oxc_span::{GetSpan, Span}; use oxc_str::Ident; -use super::evaluator::{Evaluator, FileScope, Prop, Value}; +use super::evaluator::{Evaluator, FileScope, Prop, RefKind, Value, transform_error}; use super::metadata::{ R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, @@ -120,7 +120,7 @@ pub fn extract_directive_metadata<'a>( // Track host metadata from the decorator let mut host_from_decorator: Option> = None; - let io = config_obj.map(|obj| parse_decorator_io(allocator, obj, source_text, consts)); + let io = config_obj.map(|obj| parse_decorator_io(allocator, obj, class, source_text, consts)); // Parse each property in the config object (if present) if let Some(config_obj) = config_obj { @@ -214,6 +214,7 @@ pub fn extract_directive_metadata<'a>( let fields = std::mem::replace(&mut metadata.outputs, Vec::new_in(&allocator)); metadata.outputs = merge_by_class_property(io.outputs, fields, |o| o.0.as_str()); } + resolve_member_transforms(allocator, class, source_text, consts, &mut metadata.inputs); // Merge host metadata from decorator into the existing host metadata if let Some(decorator_host) = host_from_decorator { @@ -714,6 +715,7 @@ fn scoped_transform_error(input: &str, expr: &Expression<'_>, consts: &StringCon pub(crate) fn parse_decorator_io<'a>( allocator: &'a Allocator, config: &'a ObjectExpression<'a>, + class: &'a Class<'a>, source_text: Option<&'a str>, consts: &StringConsts<'a>, ) -> DecoratorIo<'a> { @@ -743,19 +745,29 @@ pub(crate) fn parse_decorator_io<'a>( ); None } - Value::Object(_) => { - parse_input_object(allocator, &mut io, item, i, source_text, consts) - } - other => Some(io_error( - "inputs", - || { - "@Directive.inputs array can only contain strings or object literals" - .into() - }, - other, + Value::Object(_) => parse_input_object( + allocator, + &mut io, + item, + i, + class, + span, + source_text, + consts, + ), + other => Some(( + io_error( + "inputs", + || { + "@Directive.inputs array can only contain strings or object literals" + .into() + }, + other, + ), + span, )), }; - io.input_error = io.input_error.take().or(error.map(|e| (e, span))); + io.input_error = io.input_error.take().or(error); } } other => { @@ -815,16 +827,20 @@ fn parse_mapping_string(value: &str) -> (&str, &str) { (field, parts.next().unwrap_or(field)) } -/// `{ name, alias?, required?, transform? }` at `position` in the `inputs:` array. -/// Returns ngtsc's error, if any. +/// `{ name, alias?, required?, transform? }` at `position` in the `inputs:` +/// array, whose value is at `span`. Returns ngtsc's error, if any, and the +/// node it reports it on. +#[expect(clippy::too_many_arguments)] fn parse_input_object<'a>( allocator: &'a Allocator, io: &mut DecoratorIo<'a>, item: &Value<'a>, position: usize, + class: &'a Class<'a>, + span: Span, source_text: Option<&'a str>, consts: &StringConsts<'a>, -) -> Option { +) -> Option<(String, Span)> { let name = match item.prop("name").map(|p| &p.value) { Some(Value::String(name)) => name.as_str(), other => { @@ -833,26 +849,32 @@ fn parse_input_object<'a>( "Value at position {position} of @Directive.inputs array must have a \"name\" property" ) }; - return Some(io_error("inputs", message, other.unwrap_or(&Value::Undefined))); + return Some((io_error("inputs", message, other.unwrap_or(&Value::Undefined)), span)); } }; // ngtsc would read an imported alias or `required` flag from its file; oxc // can't, and guessing would compile the wrong binding. for key in ["alias", "required"] { if let Some(value) = item.prop(key).map(|p| &p.value).filter(|v| v.is_import()) { - return Some(io_error("inputs", String::new, value)); + return Some((io_error("inputs", String::new, value), span)); } } let alias = item.prop("alias").and_then(|p| p.value.as_str()).unwrap_or(name); let required = matches!(item.prop("required").map(|p| &p.value), Some(Value::Bool(true))); - let transform_function = match item.prop("transform") { - Some(Prop { expr: Some(expr), origin: None, .. }) => { - return Some(scoped_transform_error(name, expr, consts)); - } + let (transform_function, error) = match item.prop("transform") { Some(transform) => { - transform.origin.and_then(|e| convert_oxc_expression(allocator, e, source_text)) + let expr = transform_expression(allocator, transform, source_text, consts); + let error = + transform_error(transform, Some(position), name, class, consts.scope(), span) + .or_else(|| match transform { + Prop { expr: Some(written), origin: None, .. } if expr.is_none() => { + Some((scoped_transform_error(name, written, consts), span)) + } + _ => None, + }); + (expr, error) } - None => None, + None => (None, None), }; upsert_input( &mut io.inputs, @@ -864,7 +886,59 @@ fn parse_input_object<'a>( transform_function, }, ); - None + error +} + +/// The expression ngtsc emits for a transform: a function written in place, or +/// the identifier of the declaration it resolved to (`T.f` where +/// `const T = { f }` becomes `f`). For a static method that identifier isn't in +/// scope, so the written expression is kept there, as it can be written where +/// the metadata is compiled (see [`Prop::origin`]): `None` when it can't. +pub(crate) fn transform_expression<'a>( + allocator: &'a Allocator, + transform: &Prop<'a>, + source_text: Option<&'a str>, + consts: &StringConsts<'a>, +) -> Option> { + match &transform.value { + Value::Reference { name, kind: RefKind::Function(function, _) } + if consts.scope().is_top_level_function(name, function) => + { + Some(OutputAstBuilder::variable(allocator, Ident::from(allocator.alloc_str(name)))) + } + _ => convert_oxc_expression(allocator, transform.origin?, source_text), + } +} + +/// Give `@Input({ transform })` members the transform expression ngtsc emits +/// (see [`transform_expression`]), in place of the one written. +pub(crate) fn resolve_member_transforms<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + source_text: Option<&'a str>, + consts: &StringConsts<'a>, + inputs: &mut [R3InputMetadata<'a>], +) { + let evaluator = Evaluator::new(consts); + for element in &class.body.body { + let (key, decorators) = match element { + ClassElement::PropertyDefinition(p) => (&p.key, &p.decorators), + ClassElement::AccessorProperty(p) => (&p.key, &p.decorators), + ClassElement::MethodDefinition(m) => (&m.key, &m.decorators), + _ => continue, + }; + let Some(name) = key.static_name() else { continue }; + let options = super::property_decorators::input_decorator_options(decorators); + let Some(options) = options else { continue }; + let options = evaluator.evaluate(options); + let Some(transform) = options.prop("transform") else { continue }; + let Some(input) = inputs.iter_mut().find(|i| i.class_property_name == name.as_ref()) else { + continue; + }; + if let Some(expr) = transform_expression(allocator, transform, source_text, consts) { + input.transform_function = Some(expr); + } + } } /// ngtsc's `{...fromMeta, ...fromFields}` keyed by class property name: a member @@ -932,7 +1006,7 @@ pub(crate) fn angular_decorator_config<'a>( /// The first error ngtsc raises for the inputs and outputs of a `@Component` / /// `@Directive` on `class`, in the order it checks them -/// (`extractDirectiveMetadata`): `inputs:`, input members, `outputs:`, then +/// (`extractDirectiveMetadata`): `inputs:`, `@Input` members, `outputs:`, then /// output members. ngtsc stops at the first one. /// /// Each error points where ngtsc's does: the `inputs:` / `outputs:` value, or @@ -945,7 +1019,7 @@ pub fn decorator_io_errors<'a>( let Some((config, decorator_name)) = angular_decorator_config(class) else { return std::vec::Vec::new(); }; - let io = config.map(|config| parse_decorator_io(allocator, config, None, consts)); + let io = config.map(|config| parse_decorator_io(allocator, config, class, None, consts)); let (meta_inputs, meta_outputs): (std::vec::Vec<&str>, std::vec::Vec<&str>) = match &io { Some(io) => ( io.inputs.iter().map(|i| i.class_property_name.as_str()).collect(), @@ -953,18 +1027,38 @@ pub fn decorator_io_errors<'a>( ), None => Default::default(), }; + let evaluator = Evaluator::new(consts); + let input_members = || { class.body.body.iter().find_map(|element| { - let ClassElement::PropertyDefinition(prop) = element else { return None }; - let name = prop.key.static_name()?; + let (key, decorators, value) = match element { + ClassElement::PropertyDefinition(p) => (&p.key, &p.decorators, p.value.as_ref()), + ClassElement::AccessorProperty(p) => (&p.key, &p.decorators, p.value.as_ref()), + ClassElement::MethodDefinition(m) => (&m.key, &m.decorators, None), + _ => return None, + }; + let name = key.static_name()?; + // `@Input({ transform })` + let options = super::property_decorators::input_decorator_options(decorators); + if let Some(options) = options { + let span = options.span(); + let options = evaluator.evaluate(options); + if let Some(transform) = options.prop("transform") { + let error = + transform_error(transform, None, &name, class, consts.scope(), span); + if error.is_some() { + return error; + } + } + } // A signal input only collides with a metadata entry of the same name. - let value = prop.value.as_ref().filter(|_| meta_inputs.contains(&name.as_ref()))?; + let value = value.filter(|_| meta_inputs.contains(&name.as_ref()))?; let is_input = is_initializer_api_call(value, consts, &[INPUT_API, MODEL_API]); is_input.then(|| { let message = format!( "Input \"{name}\" is also declared as non-signal in @{decorator_name}." ); - (message, prop.span) + (message, element.span()) }) }) }; diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 112404cbe..a8c485769 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -51,6 +51,10 @@ pub(crate) struct FileScope<'a> { exported: HashSet<&'a str>, /// Interfaces, type aliases, classes and enums declared in the file. types: HashSet<&'a str>, + /// Where each top-level function, class and variable is first declared, as + /// ngtsc's diagnostics point at it: the whole statement (with `export`) for + /// a function or class, the declarator (`x: T`) for a variable. + declaration_spans: HashMap<&'a str, Span>, /// Import-equals aliases (`import A = NS.T`, `export import A = NS`, /// `import A = require('m')`): what each stands for. aliases: HashMap<&'a str, &'a TSModuleReference<'a>>, @@ -159,7 +163,7 @@ impl<'a> FileScope<'a> { } } Statement::ExportDeclaration(export) => { - scope.declaration(&export.declaration, true); + scope.declaration(&export.declaration, true, export.span); } Statement::ExportNamedDeclaration(export) => { for spec in &export.specifiers { @@ -170,16 +174,16 @@ impl<'a> FileScope<'a> { } Statement::ExportDefaultDeclaration(export) => match &export.declaration { ExportDefaultDeclarationKind::ClassDeclaration(class) => { - scope.class(class, true); + scope.class(class, true, export.span); } ExportDefaultDeclarationKind::FunctionDeclaration(function) => { - scope.function(function, true); + scope.function(function, true, export.span); } _ => {} }, _ => { if let Some(decl) = stmt.as_declaration() { - scope.declaration(decl, false); + scope.declaration(decl, false, decl.span()); } } } @@ -256,14 +260,14 @@ impl<'a> FileScope<'a> { let mut scope = FileScope::default(); let mut nested = std::vec::Vec::new(); for stmt in statements { - let (decl, exported) = match stmt { - Statement::ExportDeclaration(export) => (&export.declaration, true), + let (decl, exported, span) = match stmt { + Statement::ExportDeclaration(export) => (&export.declaration, true, export.span), _ => match stmt.as_declaration() { - Some(decl) => (decl, ambient), + Some(decl) => (decl, ambient, decl.span()), None => continue, }, }; - scope.declaration(decl, exported); + scope.declaration(decl, exported, span); match decl { Declaration::FunctionDeclaration(f) => { self.declared_in.insert(std::ptr::from_ref::(f) as usize, index); @@ -453,7 +457,7 @@ impl<'a> FileScope<'a> { uses.found.then(|| self.blocks[block].name.as_str()) } - fn declaration(&mut self, decl: &'a Declaration<'a>, exported: bool) { + fn declaration(&mut self, decl: &'a Declaration<'a>, exported: bool, span: Span) { let name = |scope: &mut Self, name: &'a str| { if exported { scope.exported.insert(name); @@ -473,12 +477,13 @@ impl<'a> FileScope<'a> { None => Variable::Uninitialized, }; self.variables.entry(id).or_insert(variable); + self.declaration_spans.entry(id).or_insert(var.span); name(self, id); } } } - Declaration::FunctionDeclaration(function) => self.function(function, exported), - Declaration::ClassDeclaration(class) => self.class(class, exported), + Declaration::FunctionDeclaration(function) => self.function(function, exported, span), + Declaration::ClassDeclaration(class) => self.class(class, exported, span), Declaration::TSEnumDeclaration(e) => { let id = e.id.name.as_str(); self.enums.entry(id).or_insert(e); @@ -501,12 +506,20 @@ impl<'a> FileScope<'a> { } } - fn function(&mut self, function: &'a Function<'a>, exported: bool) { + /// Whether `function` is the top-level function declaration called `name` + /// (whose name is in scope where the metadata is compiled), rather than a + /// static method. + pub(crate) fn is_top_level_function(&self, name: &str, function: &Function<'_>) -> bool { + self.functions.get(name).is_some_and(|(f, _)| std::ptr::eq(*f, function)) + } + + fn function(&mut self, function: &'a Function<'a>, exported: bool, span: Span) { let Some(id) = &function.id else { return }; let id = id.name.as_str(); if exported { self.exported.insert(id); } + self.declaration_spans.entry(id).or_insert(span); // Overloads are body-less declarations followed by the implementation. let entry = self.functions.entry(id).or_insert((function, 0)); if function.body.is_none() { @@ -517,10 +530,11 @@ impl<'a> FileScope<'a> { } } - fn class(&mut self, class: &'a Class<'a>, exported: bool) { + fn class(&mut self, class: &'a Class<'a>, exported: bool, span: Span) { let Some(id) = &class.id else { return }; let id = id.name.as_str(); self.classes.insert(id, class); + self.declaration_spans.entry(id).or_insert(span); self.types.insert(id); if exported { self.exported.insert(id); @@ -606,6 +620,49 @@ fn qualified_parts<'a>(name: &TSQualifiedName<'a>) -> Option { + Function(&'a Function<'a>), + Arrow(&'a ArrowFunctionExpression<'a>), +} + +impl<'a> FnDef<'a> { + fn is_generic(self) -> bool { + match self { + FnDef::Function(f) => f.type_parameters.is_some(), + FnDef::Arrow(f) => f.type_parameters.is_some(), + } + } + + pub(crate) fn params(self) -> &'a FormalParameters<'a> { + match self { + FnDef::Function(f) => &f.params, + FnDef::Arrow(f) => &f.params, + } + } + + fn span(self) -> Span { + match self { + FnDef::Function(f) => f.span, + FnDef::Arrow(f) => f.span, + } + } + + /// The type annotation of the first parameter (after any `this` parameter, + /// which oxc keeps separately): `Ok(None)` when there are no parameters, + /// `Err(())` when the first one has no type. + pub(crate) fn first_param_type(self) -> Result>, ()> { + let params = self.params(); + let annotation = match (params.items.first(), ¶ms.rest) { + (Some(param), _) => ¶m.type_annotation, + (None, Some(rest)) => &rest.type_annotation, + (None, None) => return Ok(None), + }; + annotation.as_ref().map(|t| Some(&t.type_annotation)).ok_or(()) + } +} + /// What a declaration reference resolves to. #[derive(Clone)] pub(crate) enum RefKind<'a> { @@ -613,9 +670,12 @@ pub(crate) enum RefKind<'a> { /// body-less declarations (overloads) that come with it. Function(&'a Function<'a>, usize), Class(&'a Class<'a>), - /// An imported binding, `ns.x` through `import * as ns`, or a value - /// computed from one: its value is in another file. - Import, + /// An imported binding, `ns.x` through `import * as ns` + /// (`namespace_member`), or a value computed from one: its value is in + /// another file. + Import { + namespace_member: bool, + }, /// An identifier with no declaration in this file that names a standard /// ECMAScript global (see [`ES_GLOBALS`]). Global, @@ -648,6 +708,9 @@ pub(crate) enum Value<'a> { /// `array.slice`, `array.concat` or `string.concat`, which ngtsc can call. Builtin(Builtin<'a>), Dynamic, + /// An arrow or function expression written directly as a property value; + /// the only place ngtsc keeps a function expression analyzable. + Function(FnDef<'a>), } /// ngtsc's `KnownFn`s (partial_evaluator/src/builtin.ts), bound to their receiver. @@ -689,7 +752,7 @@ impl<'a> Value<'a> { /// Whether this is an imported binding (or a value computed from one), which /// can't be evaluated without reading another file. pub(crate) fn is_import(&self) -> bool { - matches!(self, Value::Reference { kind: RefKind::Import, .. }) + matches!(self, Value::Reference { kind: RefKind::Import { .. }, .. }) } /// ngtsc's `describeResolvedType`, one level deep like its diagnostics. @@ -731,14 +794,16 @@ impl<'a> Value<'a> { Value::Module => "(module)".into(), Value::Reference { name, .. } | Value::Enum { name, .. } => name.clone(), Value::Builtin(_) => "Function".into(), - Value::Dynamic => "(not statically analyzable)".into(), + Value::Dynamic | Value::Function(_) => "(not statically analyzable)".into(), } } /// The chained line ngtsc's `createValueHasWrongTypeError` adds after a message. pub(crate) fn wrong_type_suffix(&self) -> String { match self { - Value::Dynamic => " Value could not be determined statically.".into(), + Value::Dynamic | Value::Function(_) => { + " Value could not be determined statically.".into() + } Value::Reference { name, .. } => format!(" Value is a reference to '{name}'."), _ => format!(" Value is of type '{}'.", self.describe()), } @@ -1131,7 +1196,11 @@ impl<'s, 'a> Evaluator<'s, 'a> { let Some(key) = self.property_key(&p.key, p.computed, depth, frame) else { return Value::Dynamic; }; - let value = self.eval(&p.value, depth, frame); + let value = match &p.value { + Expression::ArrowFunctionExpression(f) => Value::Function(FnDef::Arrow(f)), + Expression::FunctionExpression(f) => Value::Function(FnDef::Function(f)), + value => self.eval(value, depth, frame), + }; let origin = self.origin(&p.value, frame); props.push(Prop { key, value, expr: Some(&p.value), origin }); } @@ -1307,7 +1376,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { return match import.imported { Some(imported) => { let name = if imported == "default" { name } else { imported }; - Value::Reference { name: name.into(), kind: RefKind::Import } + Value::Reference { + name: name.into(), + kind: RefKind::Import { namespace_member: false }, + } } None => Value::Module, }; @@ -1600,7 +1672,11 @@ impl<'s, 'a> Evaluator<'s, 'a> { } let imported = matches!(left, TSTypeName::IdentifierReference(id) if self.consts.scope().imports.contains_key(id.name.as_str())); - let kind = if imported { RefKind::Import } else { RefKind::Other }; + let kind = if imported { + RefKind::Import { namespace_member: false } + } else { + RefKind::Other + }; Value::Reference { name: name.right.name.to_string(), kind } } _ => Value::Dynamic, @@ -1778,9 +1854,15 @@ impl<'s, 'a> Evaluator<'s, 'a> { Key::Num(n) => format_number_like_js(n), }; match object { + // A function expression reached through a property access isn't + // analyzable (`({ f: (v: string) => 1 }).f`). Value::Object(props) => { let key = key_str(); - props.into_iter().rev().find(|p| p.key == key).map_or(Value::Undefined, |p| p.value) + match props.into_iter().rev().find(|p| p.key == key) { + Some(Prop { value: Value::Function(_), .. }) => Value::Dynamic, + Some(p) => p.value, + None => Value::Undefined, + } } Value::Array(items) => match key { Key::Str("length") => Value::Number(items.len() as f64), @@ -1799,12 +1881,15 @@ impl<'s, 'a> Evaluator<'s, 'a> { Value::String(s) if matches!(key, Key::Str("concat")) => { Value::Builtin(Builtin::StringConcat(s)) } - Value::Module => Value::Reference { name: key_str(), kind: RefKind::Import }, + Value::Module => Value::Reference { + name: key_str(), + kind: RefKind::Import { namespace_member: true }, + }, Value::Reference { kind: RefKind::Class(class), .. } => { self.static_member(class, &key_str(), depth) } // The object is in another file, and so is its member. - object @ Value::Reference { kind: RefKind::Import, .. } => object, + object @ Value::Reference { kind: RefKind::Import { .. }, .. } => object, Value::Reference { kind: RefKind::Global, .. } => { Value::Reference { name: key_str(), kind: RefKind::Global } } @@ -1855,6 +1940,176 @@ impl<'s, 'a> Evaluator<'s, 'a> { } } +// ============================================================================= +// Input transforms +// ============================================================================= + +/// ngtsc's checks on an input `transform` +/// (`parseDecoratorInputTransformFunction` in +/// packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts), with +/// the node each error points at. +/// +/// `position` is the index in the `inputs:` array, `None` for +/// `@Input({ transform })`; `container` is the `inputs:` value or the `@Input` +/// argument, where ngtsc reports a transform that isn't even a reference. +pub(crate) fn transform_error( + transform: &Prop<'_>, + position: Option, + input_name: &str, + class: &Class<'_>, + scope: &FileScope<'_>, + container: Span, +) -> Option<(String, Span)> { + let value = &transform.value; + let suffix = value.wrong_type_suffix(); + let expr_span = transform.expr.map_or(container, GetSpan::span); + // ngtsc's `value.node`: where the reference is declared, or the expression + // that isn't analyzable. + let node = || value_node_span(value, expr_span, scope); + let def = match value { + Value::Function(def) => *def, + Value::Reference { kind: RefKind::Function(function, _), .. } => FnDef::Function(function), + // Imports and globals can't be inspected from this file; assume a function. + Value::Reference { + kind: RefKind::Import { namespace_member: false } | RefKind::Global, + .. + } => { + return None; + } + // ngtsc points at the declaration in the other file; the nearest + // thing in this one is the expression. + Value::Reference { kind: RefKind::Import { namespace_member: true }, .. } => { + return Some(( + format!("Input transform function could not be referenced{suffix}"), + expr_span, + )); + } + Value::Reference { .. } | Value::Dynamic => { + return Some((format!("Input transform must be a function{suffix}"), node())); + } + _ => { + let message = match position { + Some(i) => format!( + "Transform of value at position {i} of @Directive.inputs array must be a function{suffix}" + ), + None => format!("Input transform must be a function{suffix}"), + }; + return Some((message, container)); + } + }; + if def.is_generic() { + return Some((format!("Input transform function cannot be generic{suffix}"), node())); + } + if let Value::Reference { kind: RefKind::Function(_, overloads), .. } = value + && (*overloads).max(1) > 1 + { + return Some(( + format!("Input transform function cannot have multiple signatures{suffix}"), + node(), + )); + } + let conflicting = format!("ngAcceptInputType_{input_name}"); + if class.body.body.iter().any(|el| { + el.r#static() + && el + .property_key() + .and_then(PropertyKey::static_name) + .is_some_and(|n| n == conflicting) + }) { + return Some(( + format!( + "Class cannot have both a transform function on Input {input_name} and a static member called {conflicting}" + ), + node(), + )); + } + match def.first_param_type() { + Ok(None) => None, + Err(()) => Some(( + format!("Input transform function first parameter must have a type{suffix}"), + node(), + )), + Ok(Some(_)) if def.params().items.is_empty() => Some(( + format!( + "Input transform function first parameter cannot be a spread parameter{suffix}" + ), + node(), + )), + Ok(Some(ty)) => { + let mut check = UnexportedType { scope, found: false }; + check.visit_ts_type(ty); + check.found.then(|| { + ( + "Symbol must be exported in order to be used as the type of an Input transform function" + .to_string(), + ty.span(), + ) + }) + } + } +} + +/// The node of ngtsc's `value.node` for a transform: a function expression, +/// the declaration a reference resolves to, or else the expression written. +fn value_node_span(value: &Value<'_>, expr_span: Span, scope: &FileScope<'_>) -> Span { + let declared = |name: &str| scope.declaration_spans.get(name).copied(); + let span = match value { + Value::Function(def) => Some(def.span()), + Value::Reference { name, kind: RefKind::Function(function, _) } => { + if scope.is_top_level_function(name, function) { + declared(name) + } else { + static_method_span(function, scope) + } + } + Value::Reference { kind: RefKind::Class(class), .. } => { + class.id.as_ref().and_then(|id| declared(id.name.as_str())) + } + Value::Reference { name, kind: RefKind::Other } + if matches!(scope.variables.get(name.as_str()), Some(Variable::Declared(_))) => + { + declared(name) + } + _ => None, + }; + span.unwrap_or(expr_span) +} + +/// The first declaration of the static method whose implementation is +/// `function` (TypeScript's value declaration, the first overload if any). +fn static_method_span(function: &Function<'_>, scope: &FileScope<'_>) -> Option { + scope.classes.values().find_map(|class| { + let methods = || { + class.body.body.iter().filter_map(|el| match el { + ClassElement::MethodDefinition(m) if m.r#static => Some(m), + _ => None, + }) + }; + let method = methods().find(|m| std::ptr::eq(&*m.value, function))?; + let name = method.key.static_name()?; + methods().find(|m| m.key.static_name().is_some_and(|n| n == name)).map(|m| m.span) + }) +} + +/// Finds a type reference to a same-file type that isn't exported, which ngtsc +/// can't emit into the `.d.ts` (`assertEmittableInputType`). +struct UnexportedType<'s, 'a> { + scope: &'s FileScope<'a>, + found: bool, +} + +impl<'a> Visit<'a> for UnexportedType<'_, 'a> { + fn visit_ts_type_name(&mut self, name: &TSTypeName<'a>) { + if let TSTypeName::IdentifierReference(id) = name { + let id = id.name.as_str(); + if self.scope.types.contains(id) && !self.scope.exported.contains(id) { + self.found = true; + } + } + oxc_ast_visit::walk::walk_ts_type_name(self, name); + } +} + /// The identifiers an expression mentions. struct Mentions<'a>(std::vec::Vec<&'a str>); diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 71aab3981..ddde07cd6 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -31,7 +31,9 @@ pub use decorator::{ find_directive_decorator_span, }; pub(crate) use decorator::{extract_string_value, resolve_template_literal}; -pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; +pub(crate) use decorator::{ + merge_by_class_property, parse_decorator_io, resolve_member_transforms, +}; 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 77b094287..b5676fe2b 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -37,16 +37,28 @@ fn find_decorator_by_name<'a>( decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, name: &str, ) -> Option<&'a Decorator<'a>> { - decorators.iter().find(|d| match &d.expression { - Expression::CallExpression(call) => match &call.callee { - Expression::Identifier(id) => id.name == name, - _ => false, - }, + // `@Input` or, through a namespace import, `@core.Input`. + let is_name = |expr: &Expression<'_>| match expr { Expression::Identifier(id) => id.name == name, + Expression::StaticMemberExpression(m) => m.property.name == name, _ => false, + }; + decorators.iter().find(|d| match &d.expression { + Expression::CallExpression(call) => is_name(&call.callee), + expr => is_name(expr), }) } +/// The options argument of an `@Input(...)` decorator, if any. +pub(crate) fn input_decorator_options<'a>( + decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, +) -> Option<&'a Expression<'a>> { + match &find_decorator_by_name(decorators, "Input")?.expression { + Expression::CallExpression(call) => call.arguments.first()?.as_expression(), + _ => None, + } +} + /// Get the property key name as an Atom. /// /// Handles both identifier keys and string literal keys. diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 0b2fafc95..6e9fe77e8 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 380, "fixtures compared"); + assert_eq!(compared, 432, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -678,3 +678,36 @@ fn evaluation_is_bounded() { } } } + +/// ngtsc emits the method's bare name for `transform: Utils.coerce` (a static +/// method), which here would silently bind the unrelated top-level `coerce`. +/// oxc keeps the expression as written. +#[test] +fn static_method_transform_is_not_confused_with_a_same_named_function() { + let source = "import {Directive, Input} from '@angular/core'; +export function coerce(v: string) { return 1; } +class Utils { static coerce(v: boolean) { return 2; } } +@Directive({selector: '[d]'}) +export class Dir { @Input({transform: Utils.coerce}) x: any; }"; + let code = strip(&transform(source).code); + assert!(code.contains(r#"inputs:{x:[2,"x","x",Utils.coerce]}"#), "{code}"); +} + +/// A transform read through a namespace import (`core.booleanAttribute`): +/// ngtsc 22.1.7 reports that it can't reference it, at the declaration in +/// @angular/core's `.d.ts`, which the snapshot can't record (see +/// `probe: eval-nsImportMember`). oxc reports it on the expression. +#[test] +fn namespace_imported_transform_cannot_be_referenced() { + let source = "import {Directive, Input} from '@angular/core'; +import * as core from '@angular/core'; +@Directive({selector: '[d]'}) +export class Dir { @Input({transform: core.booleanAttribute}) v: any; } +"; + let message = "Input transform function could not be referenced \ + Value is a reference to 'booleanAttribute'."; + assert_eq!( + errors(&transform(source), source), + vec![(message.to_string(), "core.booleanAttribute".to_string())] + ); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 7d801e61e..966d4f3e7 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -26,6 +26,121 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "ngtsc_spec: should produce a diagnostic if the transform value is not a function", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n const NOT_A_FUNCTION: any = null!;\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: NOT_A_FUNCTION}) value!: number;\n }\n " + }, + "diagnostics": ["Input transform must be a function Value is of type 'null'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[196, 223]] + }, + { + "name": "ngtsc_spec: should produce a diagnostic if the transform value in the inputs array is not a function", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n const NOT_A_FUNCTION: any = null!;\n\n @Directive({\n selector: '[dir]',\n inputs: [{\n name: 'value',\n transform: NOT_A_FUNCTION\n }]\n })\n export class Dir {\n value!: number;\n }\n " + }, + "diagnostics": [ + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'null'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[180, 266]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if the transform function first parameter has no arguments", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: (val) => 1}) value!: number;\n }\n " + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[178, 188]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if the transform function is generic", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: (val: T) => 1}) value!: number;\n }\n " + }, + "diagnostics": [ + "Input transform function cannot be generic Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[162, 178]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if there is a conflicting coercion member", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: (val: string) => 1}) value!: number;\n\n static ngAcceptInputType_value: boolean;\n }\n " + }, + "diagnostics": [ + "Class cannot have both a transform function on Input value and a static member called ngAcceptInputType_value" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[162, 180]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if transform type is not exported", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n interface InternalType {\n foo: boolean;\n }\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: (val: InternalType) => 1}) val!: number;\n }\n " + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[242, 254]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if the transform value is not a function", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n function createTransform(outerValue: number) {\n return (innerValue: string) => outerValue;\n }\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: createTransform(1)}) value!: number;\n }\n " + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[287, 305]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if the first parameter of a transform is a spread", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n function toNumber(...value: (string | boolean)[]) { return 1; }\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "diagnostics": [ + "Input transform function first parameter cannot be a spread parameter Value is a reference to 'toNumber'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[70, 133]] + }, + { + "name": "ngtsc_spec: should produce a diangostic if a transform function has multiple signatures", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n function toNumber(value: boolean): number;\n function toNumber(value: string): number;\n function toNumber(value: boolean | string) { return 1; }\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "diagnostics": [ + "Input transform function cannot have multiple signatures Value is a reference to 'toNumber'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[70, 112]] + }, { "name": "ngtsc_spec: should wrap \"inputs\" and \"outputs\" keys if they contain unsafe characters", "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", @@ -163,6 +278,33 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "ngtsc_spec: should produce a diangostic if the transform function type cannot be referenced from the source file", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "util.ts": "\n interface InternalType {\n foo: boolean;\n }\n\n export function toNumber(val: InternalType) { return 1; }\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {toNumber} from './util';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "skip": "needs declarations from another file; oxc compiles one file at a time (imported consts, types and transforms are opaque)" + }, + { + "name": "ngtsc_spec: should produce a diangostic if a sub-type of the transform function cannot be referenced from the source file", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "util.ts": "\n interface InternalType {\n foo: boolean;\n }\n\n export function toNumber(val: {value: InternalType}) { return 1; }\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {toNumber} from './util';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "skip": "needs declarations from another file; oxc compiles one file at a time (imported consts, types and transforms are opaque)" + }, + { + "name": "ngtsc_spec: should produce a diangostic if a generic parameter of the transform function cannot be referenced from the source file", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "util.ts": "\n export interface GenericWrapper {\n value: T;\n }\n\n interface InternalType {\n foo: boolean;\n }\n\n export function toNumber(val: GenericWrapper) { return 1; }\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {toNumber} from './util';\n\n @Directive({selector: '[dir]'})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "skip": "needs declarations from another file; oxc compiles one file at a time (imported consts, types and transforms are opaque)" + }, { "name": "authoring_inputs_spec: should fail if signal input is also declared in `inputs` decorator field.", "origin": "packages/compiler-cli/test/ngtsc/authoring_inputs_spec.ts", @@ -226,6 +368,33 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "acceptance/inherit_definition_feature_spec: inputs re-declared in the `inputs` array", + "origin": "packages/core/test/acceptance/inherit_definition_feature_spec.ts", + "files": { + "test.ts": "import {Component, Directive, Input} from '@angular/core';\n\n@Directive()\nclass Base {\n @Input({transform: (v) => `${v}-transformed`}) someInput: string = '';\n}\n\n@Directive({\n selector: 'dir',\n inputs: ['someInput'],\n})\nclass ActualDir extends Base {}\n\n@Component({\n imports: [ActualDir],\n template: ``,\n})\nexport class TestCmp {}\n" + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[107, 132]] + }, + { + "name": "probe: transform-inlineArrowUntyped", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: (v) => 1}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: (v) => 1}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value could not be determined statically.", + "Input transform function first parameter must have a type Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[135, 143], [217, 225]] + }, { "name": "probe: transform-inlineArrowNoParams", "origin": "probe", @@ -248,6 +417,34 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: transform-constArrowTyped", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport const f = (v: string) => 1;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically.", + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[169, 170], [244, 245]] + }, + { + "name": "probe: transform-constArrowAnnotated", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport const f: (v: string) => number = (v) => 1;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically.", + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[184, 185], [259, 260]] + }, { "name": "probe: transform-importedFn", "origin": "probe", @@ -270,6 +467,48 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: transform-generic", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport function f(v: T) { return 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform function cannot be generic Value is a reference to 'f'.", + "Input transform function cannot be generic Value is a reference to 'f'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[66, 106], [66, 106]] + }, + { + "name": "probe: transform-overloaded", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport function f(v: string): number;\nexport function f(v: number): number;\nexport function f(v: any) { return 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform function cannot have multiple signatures Value is a reference to 'f'.", + "Input transform function cannot have multiple signatures Value is a reference to 'f'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[66, 103], [66, 103]] + }, + { + "name": "probe: transform-spread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport function f(...v: string[]) { return 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform function first parameter cannot be a spread parameter Value is a reference to 'f'.", + "Input transform function first parameter cannot be a spread parameter Value is a reference to 'f'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[66, 113], [66, 113]] + }, { "name": "probe: transform-thisOnly", "origin": "probe", @@ -292,6 +531,400 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: transform-call", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport function mk() { return (v: string) => 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: mk()}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: mk()}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically.", + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[184, 188], [262, 266]] + }, + { + "name": "probe: transform-staticMethod", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport class U { static f(v: string) { return 1; } }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: U.f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: U.f}]})\nexport class Dir2 { w: any; }\n" + }, + "skip": "ngtsc emits `f` for `transform: U.f` (a static method), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `U.f`" + }, + { + "name": "probe: transform-classRef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: U}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: U}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is a reference to 'U'.", + "Input transform must be a function Value is a reference to 'U'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[66, 83], [66, 83]] + }, + { + "name": "probe: transform-nullLit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: null}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: null}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type 'null'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'null'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 140], [189, 219]] + }, + { + "name": "probe: transform-constNull", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nconst N: any = null!;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: N}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: N}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type 'null'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'null'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 158], [207, 234]] + }, + { + "name": "probe: transform-stringLit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: \"x\"}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: \"x\"}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type 'string'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'string'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 139], [188, 217]] + }, + { + "name": "probe: transform-objLit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {a: 1}}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: {a: 1}}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '{ a: number }'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type '{ a: number }'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 142], [191, 223]] + }, + { + "name": "probe: transform-arrLit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: [1]}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: [1]}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '[number]'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type '[number]'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 139], [188, 217]] + }, + { + "name": "probe: transform-undef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: undefined}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: undefined}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type 'undefined'.", + "Transform of value at position 0 of @Directive.inputs array must be a function Value is of type 'undefined'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 145], [194, 229]] + }, + { + "name": "probe: transform-nonExportedParamType", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\ninterface Hidden { a: 1 }\nexport function f(v: Hidden) { return 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function", + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[113, 119], [113, 119]] + }, + { + "name": "probe: transform-unknownIdent", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\ndeclare const f: any;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: f}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is a reference to 'f'.", + "Input transform must be a function Value is a reference to 'f'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[80, 86], [80, 86]] + }, + { + "name": "probe: eval-letArrow", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nlet f = (v: string) => 1;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[181, 182]] + }, + { + "name": "probe: eval-varFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nvar f = function (v: string) { return 1; };\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: f}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[199, 200]] + }, + { + "name": "probe: eval-staticMissing", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: U.nope}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'undefined'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[161, 180]] + }, + { + "name": "probe: eval-instanceMethod", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U { f(v: string) { return 1; } }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: U.prototype.f}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[201, 214]] + }, + { + "name": "probe: eval-nsImportMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: core.booleanAttribute}) v: any; }\n" + }, + "diagnostics": [ + "Input transform function could not be referenced Value is a reference to 'booleanAttribute'." + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at `booleanAttribute`'s declaration in @angular/core's .d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: eval-nsImport", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: core}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '(module)'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 161]] + }, + { + "name": "probe: eval-objMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst T = { f: (v: string) => 1 };\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: T.f}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[190, 193]] + }, + { + "name": "probe: eval-typeofOp", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: typeof 1}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[156, 164]] + }, + { + "name": "probe: eval-tpl", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst X = \"x\";\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: `a${X}`}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'string'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[158, 178]] + }, + { + "name": "probe: eval-tplDyn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\ndeclare const X: any;\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: `a${X}`}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[177, 184]] + }, + { + "name": "probe: eval-newExpr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: new U()}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[173, 180]] + }, + { + "name": "probe: eval-descObjNested", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {a: 1, \"b-c\": \"x\", d: {e: 1}, f: [1], g: () => 1, h: null, i: undefined, j: true}}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '{ a: number; 'b-c': string; d: object; f: Array; g: (not statically analyzable); h: null; i: undefined; j: boolean }'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 238]] + }, + { + "name": "probe: eval-descObjRefs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\nexport function g(v: string) { return 1; }\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {a: U, b: g, c: core, d: Number}}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '{ a: U; b: g; c: (module); d: Number }'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[204, 249]] + }, + { + "name": "probe: eval-descArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: [1, \"a\", [2], {x: 1}, () => 1, null]}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '[number, string, Array, object, (not statically analyzable), null]'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 193]] + }, + { + "name": "probe: eval-descEmptyObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {}}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '{}'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 159]] + }, + { + "name": "probe: eval-descEmptyArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: []}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '[]'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[144, 159]] + }, + { + "name": "probe: eval-descSpreadObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst O = {a: 1};\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {...O, b: \"x\"}}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value is of type '{ a: number; b: string }'." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[161, 188]] + }, + { + "name": "probe: eval-descSpreadArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst A = [1];\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: [...A, \"x\"]}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '[number, string]'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[158, 182]] + }, + { + "name": "probe: eval-descComputedKey", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nconst K = \"k\";\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {[K]: 1}}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '{ k: number }'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[158, 179]] + }, + { + "name": "probe: eval-descMethodShorthand", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: {m() { return 1; }}}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[156, 175]] + }, { "name": "probe: eval-inputsObj", "origin": "probe", @@ -396,6 +1029,17 @@ "dtsImports": [], "diagnosticSpans": [[125, 131]] }, + { + "name": "probe: eval-orderFieldBeforeOutputsMeta", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", outputs: [3]})\nexport class Dir { @Input({transform: null}) v: any; }\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'null'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[158, 175]] + }, { "name": "probe: resolve-fnMultiStmt", "origin": "probe", @@ -470,6 +1114,71 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: resolve-transformIndexed", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: [(v: string) => 1][0]}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[117, 138]] + }, + { + "name": "probe: resolve-transformCondArrow", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: true ? (v: string) => 1 : null}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[117, 147]] + }, + { + "name": "probe: resolve-transformParen", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: ((v: string) => 1)}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[117, 135]] + }, + { + "name": "probe: resolve-transformAs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: ((v: string) => 1) as any}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[117, 142]] + }, + { + "name": "probe: resolve-transformObjInline", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: ({f: (v: string) => 1}).f}) v: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[117, 142]] + }, { "name": "probe: resolve-inputsTplName", "origin": "probe", @@ -600,6 +1309,28 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: review-namespacedInput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nimport * as core from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir { @core.Input({transform: null}) v: any; }" + }, + "diagnostics": ["Input transform must be a function Value is of type 'null'."], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[166, 183]] + }, + { + "name": "probe: review-aliasedInput", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nimport {Input as In} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir { @In({transform: null}) v: any; }" + }, + "diagnostics": ["Input transform must be a function Value is of type 'null'."], + "classes": {}, + "dtsImports": [], + "skip": "member decorators imported under another name (`import {Input as In}`) are matched by name throughout oxc, so `@In` isn't recognised as `@Input`" + }, { "name": "probe: review-numberSmall", "origin": "probe", From 7c06d7f4fb31c0121401f1280b631f7f8aa0967d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 14:19:36 +0800 Subject: [PATCH 2/7] fix(directive): match ngtsc's input transform checks more closely - Only a type reference named by a plain identifier is checked for being exported, so `typeof X` and `X.Y` are accepted. Interfaces and type aliases exported only through `export { X }` and type parameters (the class's, mapped, `infer`, generic signatures) are rejected. - An overloaded function or static method is checked at its first declaration. - The `ngAcceptInputType_` clash is checked for imported, namespaced and global transforms too; a global that isn't a function, or a member of one, is rejected. - A static getter, setter or uninitialized property is reported at its declaration. - `@ns.Input()` and the other member decorators are only recognised when `ns` is a namespace import of `@angular/core`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 10 + .../src/directive/evaluator.rs | 337 +++-- .../oxc_angular_compiler/src/directive/mod.rs | 1 + .../src/directive/property_decorators.rs | 38 +- .../tests/decorator_metadata_ngtsc_test.rs | 83 +- .../fixtures/decorator_metadata_ngtsc.json | 1101 ++++++++++++++++- 6 files changed, 1471 insertions(+), 99 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 7ae089c32..ead82b667 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2536,6 +2536,16 @@ pub fn transform_angular_file( } } + // `@core.Input()` and the other member decorators through a namespace + // import of `@angular/core`. + let _core_namespaces = crate::directive::CoreNamespaces::enter( + import_map + .keys() + .filter(|name| is_angular_core_namespace(&import_map, name)) + .map(ToString::to_string) + .collect(), + ); + // 2. Walk AST to find @Component decorated classes and extract metadata for stmt in &parser_ret.program.body { let (class, stmt_start) = match stmt { diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index a8c485769..58fad4faf 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -18,14 +18,17 @@ use oxc_ast::ast::{ Class, ClassElement, ComputedMemberExpression, ConditionalExpression, Declaration, ExportDefaultDeclarationKind, Expression, ForInStatement, ForOfStatement, ForStatement, ForStatementInit, ForStatementLeft, FormalParameters, Function, FunctionBody, - IdentifierReference, ImportDeclarationSpecifier, LogicalExpression, MethodDefinitionKind, - ModuleExportName, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, PropertyKind, - Statement, StaticBlock, StaticMemberExpression, Super, SwitchStatement, TSEnumDeclaration, - TSEnumMemberName, TSInterfaceDeclaration, TSLiteral, TSModuleReference, TSNamespaceDeclaration, - TSNamespaceDeclarationBody, TSQualifiedName, TSTupleElement, TSType, TSTypeAliasDeclaration, - TSTypeName, TSTypeOperatorOperator, TSTypeParameterDeclaration, TSTypeParameterInstantiation, - TSTypeQueryExprName, TemplateLiteral, ThisExpression, UnaryExpression, VariableDeclaration, - VariableDeclarationKind, + IdentifierReference, ImportDeclarationSpecifier, LogicalExpression, MethodDefinition, + MethodDefinitionKind, ModuleExportName, ObjectExpression, ObjectPropertyKind, Program, + PropertyKey, PropertyKind, Statement, StaticBlock, StaticMemberExpression, Super, + SwitchStatement, TSCallSignatureDeclaration, TSConditionalType, + TSConstructSignatureDeclaration, TSConstructorType, TSEnumDeclaration, TSEnumMemberName, + TSFunctionType, TSInferType, TSInterfaceDeclaration, TSLiteral, TSMappedType, + TSMethodSignature, TSModuleReference, TSNamespaceDeclaration, TSNamespaceDeclarationBody, + TSQualifiedName, TSTupleElement, TSType, TSTypeAliasDeclaration, TSTypeName, + TSTypeOperatorOperator, TSTypeParameterDeclaration, TSTypeParameterInstantiation, + TSTypeQueryExprName, TSTypeReference, TemplateLiteral, ThisExpression, UnaryExpression, + VariableDeclaration, VariableDeclarationKind, }; use oxc_ast_visit::{Visit, walk}; use oxc_span::{GetSpan, Span}; @@ -44,13 +47,18 @@ pub(crate) struct FileScope<'a> { /// there's none) and how many body-less declarations (overloads, /// `declare function`) come with it. functions: HashMap<&'a str, (&'a Function<'a>, usize)>, + /// The first declaration of each function (its first overload, if any), + /// which is the one ngtsc checks as an input transform. + first_functions: HashMap<&'a str, &'a Function<'a>>, enums: HashMap<&'a str, &'a TSEnumDeclaration<'a>>, classes: HashMap<&'a str, &'a Class<'a>>, imports: HashMap<&'a str, Import<'a>>, - /// Names exported from the file (`export ...` and `export { ... }`). + /// Names exported from the file (`export ...`, `export { ... }` and + /// `export default name`). exported: HashSet<&'a str>, - /// Interfaces, type aliases, classes and enums declared in the file. - types: HashSet<&'a str>, + /// Interfaces, type aliases, classes and enums declared in the file, and + /// whether the first declaration of each is itself marked `export`. + types: HashMap<&'a str, bool>, /// Where each top-level function, class and variable is first declared, as /// ngtsc's diagnostics point at it: the whole statement (with `export`) for /// a function or class, the declarator (`x: T`) for a variable. @@ -179,6 +187,12 @@ impl<'a> FileScope<'a> { ExportDefaultDeclarationKind::FunctionDeclaration(function) => { scope.function(function, true, export.span); } + ExportDefaultDeclarationKind::TSInterfaceDeclaration(i) => { + scope.types.entry(i.id.name.as_str()).or_insert(true); + } + ExportDefaultDeclarationKind::Identifier(id) => { + scope.exported.insert(id.name.as_str()); + } _ => {} }, _ => { @@ -487,15 +501,15 @@ impl<'a> FileScope<'a> { Declaration::TSEnumDeclaration(e) => { let id = e.id.name.as_str(); self.enums.entry(id).or_insert(e); - self.types.insert(id); + self.types.entry(id).or_insert(exported); name(self, id); } Declaration::TSInterfaceDeclaration(i) => { - self.types.insert(i.id.name.as_str()); + self.types.entry(i.id.name.as_str()).or_insert(exported); name(self, i.id.name.as_str()); } Declaration::TSTypeAliasDeclaration(t) => { - self.types.insert(t.id.name.as_str()); + self.types.entry(t.id.name.as_str()).or_insert(exported); name(self, t.id.name.as_str()); } Declaration::TSImportEqualsDeclaration(alias) => { @@ -513,6 +527,32 @@ impl<'a> FileScope<'a> { self.functions.get(name).is_some_and(|(f, _)| std::ptr::eq(*f, function)) } + /// The first declaration of the function or static method `name` whose + /// implementation is `function`: its first overload, if any. That's + /// TypeScript's value declaration, the one ngtsc checks as a transform. + fn first_declaration(&self, name: &str, function: &'a Function<'a>) -> &'a Function<'a> { + if self.is_top_level_function(name, function) { + return self.first_functions.get(name).copied().unwrap_or(function); + } + self.first_static_method(function).map_or(function, |m| &m.value) + } + + /// The first declaration of the static method whose implementation is + /// `function`. + fn first_static_method(&self, function: &Function<'_>) -> Option<&'a MethodDefinition<'a>> { + self.classes.values().find_map(|class| { + let methods = || { + class.body.body.iter().filter_map(|el| match el { + ClassElement::MethodDefinition(m) if m.r#static => Some(&**m), + _ => None, + }) + }; + let method = methods().find(|m| std::ptr::eq(&*m.value, function))?; + let name = method.key.static_name()?; + methods().find(|m| m.key.static_name().is_some_and(|n| n == name)) + }) + } + fn function(&mut self, function: &'a Function<'a>, exported: bool, span: Span) { let Some(id) = &function.id else { return }; let id = id.name.as_str(); @@ -520,6 +560,7 @@ impl<'a> FileScope<'a> { self.exported.insert(id); } self.declaration_spans.entry(id).or_insert(span); + self.first_functions.entry(id).or_insert(function); // Overloads are body-less declarations followed by the implementation. let entry = self.functions.entry(id).or_insert((function, 0)); if function.body.is_none() { @@ -535,12 +576,29 @@ impl<'a> FileScope<'a> { let id = id.name.as_str(); self.classes.insert(id, class); self.declaration_spans.entry(id).or_insert(span); - self.types.insert(id); + self.types.entry(id).or_insert(exported); if exported { self.exported.insert(id); } } + /// For a type declared in the file, whether ngtsc's `isStaticallyExported` + /// holds for the declaration the name resolves to: `None` for a name the + /// file doesn't declare as a type. + /// + /// A name with a value declaration (a class or enum, or a variable or + /// function merged with the type) resolves to it, and a value is also + /// exported by `export { X }` or `export default X`. An interface or type + /// alias has no value declaration: only its own `export` counts. + fn type_is_exported(&self, name: &str) -> Option { + let exported_itself = *self.types.get(name)?; + let has_value = self.classes.contains_key(name) + || self.enums.contains_key(name) + || self.variables.contains_key(name) + || self.functions.contains_key(name); + Some(if has_value { self.exported.contains(name) } else { exported_itself }) + } + pub(crate) fn import(&self, name: &str) -> Option> { self.imports.get(name).copied() } @@ -679,6 +737,9 @@ pub(crate) enum RefKind<'a> { /// An identifier with no declaration in this file that names a standard /// ECMAScript global (see [`ES_GLOBALS`]). Global, + /// A static getter or setter, or a static property without an + /// initializer, declared at the span. + StaticMember(Span), /// Any other declaration (a `declare`d variable, an enum member, ...). Other, } @@ -909,6 +970,22 @@ const ES_GLOBALS: &[&str] = &[ "WeakSet", ]; +/// The globals in [`ES_GLOBALS`] declared as functions (`declare function`), +/// each with a single signature; the others are `declare var`s. +const ES_GLOBAL_FUNCTIONS: &[&str] = &[ + "decodeURI", + "decodeURIComponent", + "encodeURI", + "encodeURIComponent", + "escape", + "eval", + "isFinite", + "isNaN", + "parseFloat", + "parseInt", + "unescape", +]; + /// Bounds a chain of import-equals aliases (`import A = B; import B = A;`). const MAX_ALIASES: u16 = 64; @@ -1890,9 +1967,8 @@ impl<'s, 'a> Evaluator<'s, 'a> { } // The object is in another file, and so is its member. object @ Value::Reference { kind: RefKind::Import { .. }, .. } => object, - Value::Reference { kind: RefKind::Global, .. } => { - Value::Reference { name: key_str(), kind: RefKind::Global } - } + // Including a member of a global (`Math.round`): ngtsc only + // resolves the global's own declaration. _ => Value::Dynamic, } } @@ -1906,7 +1982,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { if m.r#static && m.key.static_name().is_some_and(|n| n == key) => { if m.kind != MethodDefinitionKind::Method { - return Value::Reference { name: key.into(), kind: RefKind::Other }; + return Value::Reference { + name: key.into(), + kind: RefKind::StaticMember(m.span), + }; } if m.value.body.is_none() { overloads += 1; @@ -1925,7 +2004,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { let block = self.consts.scope().block_of(class); self.eval(value, depth, &Frame::at(block)) } - None => Value::Reference { name: key.into(), kind: RefKind::Other }, + None => Value::Reference { + name: key.into(), + kind: RefKind::StaticMember(p.span), + }, }; } _ => {} @@ -1952,37 +2034,66 @@ impl<'s, 'a> Evaluator<'s, 'a> { /// `position` is the index in the `inputs:` array, `None` for /// `@Input({ transform })`; `container` is the `inputs:` value or the `@Input` /// argument, where ngtsc reports a transform that isn't even a reference. -pub(crate) fn transform_error( - transform: &Prop<'_>, +pub(crate) fn transform_error<'a>( + transform: &Prop<'a>, position: Option, input_name: &str, - class: &Class<'_>, - scope: &FileScope<'_>, + class: &Class<'a>, + scope: &FileScope<'a>, container: Span, ) -> Option<(String, Span)> { let value = &transform.value; let suffix = value.wrong_type_suffix(); let expr_span = transform.expr.map_or(container, GetSpan::span); // ngtsc's `value.node`: where the reference is declared, or the expression - // that isn't analyzable. + // that isn't analyzable. For a declaration in another file, the nearest + // thing in this one is the expression. let node = || value_node_span(value, expr_span, scope); + let conflicting = format!("ngAcceptInputType_{input_name}"); + let clash = || { + class + .body + .body + .iter() + .any(|el| { + el.r#static() + && el + .property_key() + .and_then(PropertyKey::static_name) + .is_some_and(|n| n == conflicting) + }) + .then(|| { + ( + format!( + "Class cannot have both a transform function on Input {input_name} and a static member called {conflicting}" + ), + node(), + ) + }) + }; let def = match value { Value::Function(def) => *def, - Value::Reference { kind: RefKind::Function(function, _), .. } => FnDef::Function(function), - // Imports and globals can't be inspected from this file; assume a function. - Value::Reference { - kind: RefKind::Import { namespace_member: false } | RefKind::Global, - .. - } => { - return None; + Value::Reference { name, kind: RefKind::Function(function, _) } => { + FnDef::Function(scope.first_declaration(name, function)) + } + // An imported function can't be inspected from this file, so whether + // it's generic or overloaded is unknown; the name clash is checked + // after those. + Value::Reference { kind: RefKind::Import { namespace_member: false }, .. } => { + return clash(); } - // ngtsc points at the declaration in the other file; the nearest - // thing in this one is the expression. + // ngtsc can't name `ns.f` in the compiled file. Value::Reference { kind: RefKind::Import { namespace_member: true }, .. } => { - return Some(( - format!("Input transform function could not be referenced{suffix}"), - expr_span, - )); + return clash().or_else(|| { + Some((format!("Input transform function could not be referenced{suffix}"), node())) + }); + } + // A `declare function` of TypeScript's library: neither generic nor + // overloaded. + Value::Reference { name, kind: RefKind::Global } + if ES_GLOBAL_FUNCTIONS.contains(&name.as_str()) => + { + return clash(); } Value::Reference { .. } | Value::Dynamic => { return Some((format!("Input transform must be a function{suffix}"), node())); @@ -2008,20 +2119,8 @@ pub(crate) fn transform_error( node(), )); } - let conflicting = format!("ngAcceptInputType_{input_name}"); - if class.body.body.iter().any(|el| { - el.r#static() - && el - .property_key() - .and_then(PropertyKey::static_name) - .is_some_and(|n| n == conflicting) - }) { - return Some(( - format!( - "Class cannot have both a transform function on Input {input_name} and a static member called {conflicting}" - ), - node(), - )); + if let Some(error) = clash() { + return Some(error); } match def.first_param_type() { Ok(None) => None, @@ -2036,8 +2135,14 @@ pub(crate) fn transform_error( node(), )), Ok(Some(ty)) => { - let mut check = UnexportedType { scope, found: false }; - check.visit_ts_type(ty); + // An arrow written in a member's `@Input` sees the class's type + // parameters. + let in_class = matches!(value, Value::Function(_)) + && class.body.span.contains_inclusive(def.span()); + let class_params = class.type_parameters.as_deref().filter(|_| in_class); + let mut check = + UnexportedType { scope, type_params: std::vec::Vec::new(), found: false }; + check.with_params(class_params, |check| check.visit_ts_type(ty)); check.found.then(|| { ( "Symbol must be exported in order to be used as the type of an Input transform function" @@ -2059,12 +2164,13 @@ fn value_node_span(value: &Value<'_>, expr_span: Span, scope: &FileScope<'_>) -> if scope.is_top_level_function(name, function) { declared(name) } else { - static_method_span(function, scope) + scope.first_static_method(function).map(|m| m.span) } } Value::Reference { kind: RefKind::Class(class), .. } => { class.id.as_ref().and_then(|id| declared(id.name.as_str())) } + Value::Reference { kind: RefKind::StaticMember(span), .. } => Some(*span), Value::Reference { name, kind: RefKind::Other } if matches!(scope.variables.get(name.as_str()), Some(Variable::Declared(_))) => { @@ -2075,39 +2181,110 @@ fn value_node_span(value: &Value<'_>, expr_span: Span, scope: &FileScope<'_>) -> span.unwrap_or(expr_span) } -/// The first declaration of the static method whose implementation is -/// `function` (TypeScript's value declaration, the first overload if any). -fn static_method_span(function: &Function<'_>, scope: &FileScope<'_>) -> Option { - scope.classes.values().find_map(|class| { - let methods = || { - class.body.body.iter().filter_map(|el| match el { - ClassElement::MethodDefinition(m) if m.r#static => Some(m), - _ => None, - }) - }; - let method = methods().find(|m| std::ptr::eq(&*m.value, function))?; - let name = method.key.static_name()?; - methods().find(|m| m.key.static_name().is_some_and(|n| n == name)).map(|m| m.span) - }) -} - -/// Finds a type reference to a same-file type that isn't exported, which ngtsc -/// can't emit into the `.d.ts` (`assertEmittableInputType`). +/// Finds what ngtsc's `assertEmittableInputType` rejects in a transform's +/// parameter type: a type reference whose name is a plain identifier that +/// resolves to a declaration in this file that isn't exported, which can't be +/// written into the `.d.ts`. A type parameter never is. `typeof X` and the +/// left side of `X.Y` aren't type references, so they're never checked. struct UnexportedType<'s, 'a> { scope: &'s FileScope<'a>, + /// The type parameters in scope, innermost last. + type_params: std::vec::Vec<&'a str>, found: bool, } +impl<'a> UnexportedType<'_, 'a> { + /// Visits `walk` with `params` in scope. + fn with_params( + &mut self, + params: Option<&TSTypeParameterDeclaration<'a>>, + walk: impl FnOnce(&mut Self), + ) { + let len = self.type_params.len(); + self.type_params + .extend(params.into_iter().flat_map(|d| &d.params).map(|p| p.name.name.as_str())); + walk(self); + self.type_params.truncate(len); + } +} + impl<'a> Visit<'a> for UnexportedType<'_, 'a> { - fn visit_ts_type_name(&mut self, name: &TSTypeName<'a>) { - if let TSTypeName::IdentifierReference(id) = name { - let id = id.name.as_str(); - if self.scope.types.contains(id) && !self.scope.exported.contains(id) { - self.found = true; - } + fn visit_ts_type_reference(&mut self, reference: &TSTypeReference<'a>) { + if let TSTypeName::IdentifierReference(id) = &reference.type_name { + let name = id.name.as_str(); + // Innermost first: a type parameter shadows a declaration. + let exported = if self.type_params.contains(&name) { + Some(false) + } else { + self.scope.type_is_exported(name) + }; + self.found |= exported == Some(false); + } + if let Some(args) = &reference.type_arguments { + self.visit_ts_type_parameter_instantiation(args); } - oxc_ast_visit::walk::walk_ts_type_name(self, name); } + + fn visit_ts_mapped_type(&mut self, ty: &TSMappedType<'a>) { + let len = self.type_params.len(); + self.type_params.push(ty.key.name.as_str()); + walk::walk_ts_mapped_type(self, ty); + self.type_params.truncate(len); + } + + fn visit_ts_function_type(&mut self, ty: &TSFunctionType<'a>) { + self.with_params(ty.type_parameters.as_deref(), |v| walk::walk_ts_function_type(v, ty)); + } + + fn visit_ts_constructor_type(&mut self, ty: &TSConstructorType<'a>) { + self.with_params(ty.type_parameters.as_deref(), |v| walk::walk_ts_constructor_type(v, ty)); + } + + fn visit_ts_method_signature(&mut self, sig: &TSMethodSignature<'a>) { + self.with_params(sig.type_parameters.as_deref(), |v| { + walk::walk_ts_method_signature(v, sig) + }); + } + + fn visit_ts_call_signature_declaration(&mut self, sig: &TSCallSignatureDeclaration<'a>) { + self.with_params(sig.type_parameters.as_deref(), |v| { + walk::walk_ts_call_signature_declaration(v, sig); + }); + } + + fn visit_ts_construct_signature_declaration( + &mut self, + sig: &TSConstructSignatureDeclaration<'a>, + ) { + self.with_params(sig.type_parameters.as_deref(), |v| { + walk::walk_ts_construct_signature_declaration(v, sig); + }); + } + + /// `infer U` declares `U` for the `extends` clause and the true branch. + fn visit_ts_conditional_type(&mut self, ty: &TSConditionalType<'a>) { + self.visit_ts_type(&ty.check_type); + let mut infers = InferNames(std::vec::Vec::new()); + infers.visit_ts_type(&ty.extends_type); + let len = self.type_params.len(); + self.type_params.extend(infers.0); + self.visit_ts_type(&ty.extends_type); + self.visit_ts_type(&ty.true_type); + self.type_params.truncate(len); + self.visit_ts_type(&ty.false_type); + } +} + +/// The type parameters `infer` declares in a conditional type's `extends` +/// clause (not those of a conditional type nested in it). +struct InferNames<'a>(std::vec::Vec<&'a str>); + +impl<'a> Visit<'a> for InferNames<'a> { + fn visit_ts_infer_type(&mut self, ty: &TSInferType<'a>) { + self.0.push(ty.type_parameter.name.name.as_str()); + } + + fn visit_ts_conditional_type(&mut self, _: &TSConditionalType<'a>) {} } /// The identifiers an expression mentions. diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index ddde07cd6..9a9f1cb12 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -39,6 +39,7 @@ pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, }; +pub(crate) use property_decorators::CoreNamespaces; pub use property_decorators::{ extract_content_queries, extract_host_bindings, extract_host_listeners, extract_input_metadata, extract_output_metadata, extract_view_queries, diff --git a/crates/oxc_angular_compiler/src/directive/property_decorators.rs b/crates/oxc_angular_compiler/src/directive/property_decorators.rs index b5676fe2b..c5873b64a 100644 --- a/crates/oxc_angular_compiler/src/directive/property_decorators.rs +++ b/crates/oxc_angular_compiler/src/directive/property_decorators.rs @@ -11,6 +11,8 @@ //! These decorators are found on class properties and methods, and define //! how the directive/component interacts with its parent context. +use std::cell::RefCell; + use oxc_allocator::{Allocator, Vec}; use oxc_ast::ast::{ Argument, ArrayExpressionElement, Class, ClassElement, Decorator, Expression, @@ -26,21 +28,53 @@ use crate::output::oxc_converter::convert_oxc_expression; // Helper Functions // ============================================================================ +thread_local! { + /// The local names of `import * as ns from '@angular/core'` in the file + /// being compiled (see [`CoreNamespaces`]). + static CORE_NAMESPACES: RefCell> = + const { RefCell::new(std::vec::Vec::new()) }; +} + +/// While alive, member decorators written through these namespace imports of +/// `@angular/core` (`@core.Input()`) are recognised. ngtsc only reads a +/// namespaced member decorator whose namespace imports `@angular/core`, and the +/// functions here that extract member decorators don't see the file's imports. +pub(crate) struct CoreNamespaces(std::vec::Vec); + +impl CoreNamespaces { + pub(crate) fn enter(names: std::vec::Vec) -> Self { + Self(CORE_NAMESPACES.with(|current| current.replace(names))) + } +} + +impl Drop for CoreNamespaces { + fn drop(&mut self) { + let previous = std::mem::take(&mut self.0); + CORE_NAMESPACES.with(|current| *current.borrow_mut() = previous); + } +} + /// Find a decorator by name from a list of decorators. /// /// Searches for decorators that are either: /// - Simple identifiers: `@Input` /// - Call expressions: `@Input()` or `@Input('alias')` +/// - Either of those through a namespace import of `@angular/core` (see +/// [`CoreNamespaces`]): `@core.Input()` /// /// Returns the first matching decorator. fn find_decorator_by_name<'a>( decorators: &'a oxc_allocator::Vec<'a, Decorator<'a>>, name: &str, ) -> Option<&'a Decorator<'a>> { - // `@Input` or, through a namespace import, `@core.Input`. + let is_core_namespace = + |ns: &str| CORE_NAMESPACES.with(|names| names.borrow().iter().any(|n| n == ns)); let is_name = |expr: &Expression<'_>| match expr { Expression::Identifier(id) => id.name == name, - Expression::StaticMemberExpression(m) => m.property.name == name, + Expression::StaticMemberExpression(m) => { + m.property.name == name + && matches!(&m.object, Expression::Identifier(ns) if is_core_namespace(&ns.name)) + } _ => false, }; decorators.iter().find(|d| match &d.expression { diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 6e9fe77e8..48b69456a 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 432, "fixtures compared"); + assert_eq!(compared, 496, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -711,3 +711,84 @@ export class Dir { @Input({transform: core.booleanAttribute}) v: any; } vec![(message.to_string(), "core.booleanAttribute".to_string())] ); } + +/// A transform declared in another file (an import, a namespace member or a +/// global from TypeScript's lib): ngtsc 22.1.7 reports these at that +/// declaration, which the snapshot can't record (the fixtures below are +/// skipped for that reason, with the diagnostics ngtsc reported). oxc reports +/// the same message on the expression. +#[test] +fn transform_declared_in_another_file_is_reported_on_the_expression() { + let fixtures: Value = serde_json::from_str(FIXTURES).unwrap(); + let cases = [ + ("probe: transform-clashImported", "booleanAttribute"), + ("probe: transform-clashImportedMeta", "booleanAttribute"), + ("probe: transform-clashGlobal", "parseInt"), + ("probe: transform-globalNumber", "Number"), + ("probe: transform-globalNumberClash", "Number"), + ("probe: transform-globalString", "String"), + ("probe: transform-nsClash", "u.toNum"), + ]; + for (name, expression) in cases { + let fixture = fixtures["fixtures"] + .as_array() + .unwrap() + .iter() + .find(|f| f["name"] == name) + .unwrap_or_else(|| panic!("{name}")); + let source = fixture["files"]["test.ts"].as_str().unwrap(); + let expected: Vec<(String, String)> = fixture["diagnostics"] + .as_array() + .unwrap() + .iter() + .map(|d| (d.as_str().unwrap().to_string(), expression.to_string())) + .collect(); + assert_eq!(errors(&transform(source), source), expected, "{name}"); + } +} + +/// ngtsc checks an overloaded static method's first declaration, not its +/// implementation (checked with @angular/compiler-cli 22.1.7, which compiles +/// this; the snapshot can't hold it because ngtsc emits the method's bare name, +/// see `static_method_transform_is_not_confused_with_a_same_named_function`). +#[test] +fn overloaded_static_method_transform_is_checked_at_its_first_declaration() { + let source = "import {Directive, Input} from '@angular/core'; +interface Foo {} +class U { static c(v: string): number; static c(v: string | Foo) { return 1; } } +@Directive({selector: '[d]'}) +export class Dir { @Input({transform: U.c}) x!: number; } +"; + let result = transform(source); + assert_eq!(errors(&result, source), vec![]); + let code = strip(&result.code); + assert!(code.contains(r#"inputs:{x:[2,"x","x",U.c]}"#), "{code}"); +} + +/// Member decorators through a namespace import are Angular's only when the +/// namespace imports `@angular/core` (ngtsc 22.1.7 compiles the query, host +/// binding and listener below for `core`, and none of them for `foreign`). +/// The snapshot compares the inputs and outputs of both +/// (`probe: transform-coreNamespaceMembers` and +/// `probe: transform-foreignNamespaceMembers`). +#[test] +fn namespaced_member_decorators_need_an_angular_core_namespace() { + let members = " +export class Cmp { + @NS.ViewChild('ref') ref: any; + @NS.HostBinding('class.a') a = true; + @NS.HostListener('click') onClick() {} +} +"; + let core = "import * as NS from '@angular/core'; +@NS.Component({selector: 'c', template: '
'})"; + let foreign = "import {Component} from '@angular/core'; +import * as NS from 'foreign-decorators'; +@Component({selector: 'c', template: '
'})"; + for (header, compiled) in [(core, true), (foreign, false)] { + let code = transform(&format!("{header}{members}")).code; + for part in ["viewQuery", "ɵɵclassProp(\"a\"", "ɵɵlistener(\"click\""] { + assert_eq!(code.contains(part), compiled, "{part} in\n{code}"); + } + } +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 966d4f3e7..ba8c9e750 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -393,7 +393,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[135, 143], [217, 225]] + "diagnosticSpans": [ + [135, 143], + [217, 225] + ] }, { "name": "probe: transform-inlineArrowNoParams", @@ -429,7 +432,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[169, 170], [244, 245]] + "diagnosticSpans": [ + [169, 170], + [244, 245] + ] }, { "name": "probe: transform-constArrowAnnotated", @@ -443,7 +449,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[184, 185], [259, 260]] + "diagnosticSpans": [ + [184, 185], + [259, 260] + ] }, { "name": "probe: transform-importedFn", @@ -479,7 +488,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[66, 106], [66, 106]] + "diagnosticSpans": [ + [66, 106], + [66, 106] + ] }, { "name": "probe: transform-overloaded", @@ -493,7 +505,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[66, 103], [66, 103]] + "diagnosticSpans": [ + [66, 103], + [66, 103] + ] }, { "name": "probe: transform-spread", @@ -507,7 +522,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[66, 113], [66, 113]] + "diagnosticSpans": [ + [66, 113], + [66, 113] + ] }, { "name": "probe: transform-thisOnly", @@ -543,7 +561,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[184, 188], [262, 266]] + "diagnosticSpans": [ + [184, 188], + [262, 266] + ] }, { "name": "probe: transform-staticMethod", @@ -565,7 +586,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[66, 83], [66, 83]] + "diagnosticSpans": [ + [66, 83], + [66, 83] + ] }, { "name": "probe: transform-nullLit", @@ -579,7 +603,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[123, 140], [189, 219]] + "diagnosticSpans": [ + [123, 140], + [189, 219] + ] }, { "name": "probe: transform-constNull", @@ -593,7 +620,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[144, 158], [207, 234]] + "diagnosticSpans": [ + [144, 158], + [207, 234] + ] }, { "name": "probe: transform-stringLit", @@ -607,7 +637,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[123, 139], [188, 217]] + "diagnosticSpans": [ + [123, 139], + [188, 217] + ] }, { "name": "probe: transform-objLit", @@ -621,7 +654,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[123, 142], [191, 223]] + "diagnosticSpans": [ + [123, 142], + [191, 223] + ] }, { "name": "probe: transform-arrLit", @@ -635,7 +671,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[123, 139], [188, 217]] + "diagnosticSpans": [ + [123, 139], + [188, 217] + ] }, { "name": "probe: transform-undef", @@ -649,7 +688,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[123, 145], [194, 229]] + "diagnosticSpans": [ + [123, 145], + [194, 229] + ] }, { "name": "probe: transform-nonExportedParamType", @@ -663,7 +705,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[113, 119], [113, 119]] + "diagnosticSpans": [ + [113, 119], + [113, 119] + ] }, { "name": "probe: transform-unknownIdent", @@ -677,7 +722,10 @@ ], "classes": {}, "dtsImports": [], - "diagnosticSpans": [[80, 86], [80, 86]] + "diagnosticSpans": [ + [80, 86], + [80, 86] + ] }, { "name": "probe: eval-letArrow", @@ -4805,6 +4853,1027 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: transform-typeofEnumKeyof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nenum Size { S, M }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: keyof typeof Size) => Size[v]}) x!: Size;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>Size[v]]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-typeofClass", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass Local {}\nexport function toNum(v: typeof Local) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-instanceTypeTypeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass Local {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: InstanceType) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-returnTypeTypeofMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass Local { static create(v: string) { return new Local(); } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: ReturnType) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-qualifiedClassNamespace", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass C {}\nnamespace C { export type T = string; }\nexport function toNum(v: C.T) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-qualifiedInterfaceNamespace", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo { a: string }\nnamespace Foo { export type T = string; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Foo.T) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-typeofEnum", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nenum E { A }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: typeof E) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-enumUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nenum E { A }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: E) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[153, 154]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-indexedAccessUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo { a: string }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Foo['a']) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[168, 176]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-typeArgUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Map) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[157, 173]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-typeLiteralMethodUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: {m(x: Foo): void}) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[157, 174]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadImplTypeUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo { a: string }\nexport function toNum(v: string): number;\nexport function toNum(v: string | Foo): number { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadFirstTypeUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo {}\nexport function toNum(v: Foo): number;\nexport function toNum(v: any) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[108, 111]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadFirstUntyped", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(v): number;\nexport function toNum(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value is a reference to 'toNum'." + ], + "diagnosticSpans": [[66, 99]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadFirstNoParams", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(): number;\nexport function toNum(v?: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadFirstSpread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(...v: string[]): number;\nexport function toNum(v: any) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform function first parameter cannot be a spread parameter Value is a reference to 'toNum'." + ], + "diagnosticSpans": [[66, 112]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-overloadStaticFirstTypeUnexported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo {}\nclass U { static c(v: Foo): number; static c(v: any) { return 1; } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: U.c}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[105, 108]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-constTernary", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nconst isProd = false;\nexport function a(v: string) { return 1; }\nexport function b(v: string) { return 2; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: isProd ? a : b}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",b]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-literalTernary", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: true ? toNum : toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-optionalChain", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nfunction toNum(v: string) { return 1; }\nconst T = { f: toNum };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: T?.f}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-optionalChainExported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(v: string) { return 1; }\nconst T = { f: toNum };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: T?.f}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-ternaryMeta", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nconst isProd = false;\nexport function a(v: string) { return 1; }\nexport function b(v: string) { return 2; }\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: isProd ? a : b}]})\nexport class Dir {\n x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",b]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-clashImported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: booleanAttribute}) x!: boolean;\n static ngAcceptInputType_x: boolean | string;\n}\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in @angular/core's .d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-clashImportedMeta", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: booleanAttribute}]})\nexport class Dir {\n x!: boolean;\n static ngAcceptInputType_x: boolean | string;\n}\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in @angular/core's .d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-clashGlobal", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: parseInt}) x!: number;\n static ngAcceptInputType_x: string;\n}\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es5.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-letUninitTyped", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nlet f: (v: string) => number;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: f}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'undefined'."], + "diagnosticSpans": [[154, 168]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-letUninit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nlet f;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: f}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'undefined'."], + "diagnosticSpans": [[131, 145]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-enumAsTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nenum E { A }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: E}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type '{ A: E }'."], + "diagnosticSpans": [[137, 151]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-staticGetter", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass U { static get c() { return (v: string) => 1; } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: U.c}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'c'."], + "diagnosticSpans": [[76, 119]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-interfaceExportedLater", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo { a: string }\nexport function toNum(v: Foo) { return 1; }\nexport { Foo };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[119, 122]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-typeExportedLater", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ntype Foo = { a: string };\nexport function toNum(v: Foo) { return 1; }\nexport type { Foo };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[117, 120]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-interfaceExportedAs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo { a: string }\nexport function toNum(v: Foo) { return 1; }\nexport { Foo as Bar };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[119, 122]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-interfaceExportDefault", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ninterface Foo {}\nexport function toNum(v: Foo) { return 1; }\nexport default Foo;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[108, 111]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-classExportedLater", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass Foo {}\nexport function toNum(v: Foo) { return 1; }\nexport { Foo };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-classExportedAs", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass C {}\nexport { C as D };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: C) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-enumExportedLater", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nenum E { A }\nexport { E };\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: E) => 1}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-classExportDefault", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport default class Foo {}\nexport function toNum(v: Foo) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-classExportDefaultLater", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass Foo {}\nexport function toNum(v: Foo) { return 1; }\nexport default Foo;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",toNum]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-classTypeParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Tp) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[144, 146]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-classTypeParamShadowsExported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport interface Foo {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Foo) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[169, 172]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-mappedTypeParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: {[K in 'a' | 'b']: K}) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[140, 161]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-inferTypeParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: string extends infer U ? U : never) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[140, 174]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-fnTypeParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: (x: T) => T) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[140, 154]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-fnTypeParamShadowsExported", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport interface Foo {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: (x: Foo) => void) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[164, 185]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-methodSigTypeParam", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: {m(x: T): void}) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[140, 158]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-conditionalFalseBranchOuter", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport interface Foo {}\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: string extends infer U ? U : Foo) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[164, 196]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-declareGlobal", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\ndeclare global { interface Gl { a: string } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: Gl) => 1}) x!: number;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "classes": {}, + "dtsImports": [], + "skip": "a type declared in `declare global` is a new global unless it augments one of TypeScript's lib types, which ngtsc resolves to the lib file; oxc doesn't read the lib files, so it can't tell the two apart and accepts both" + }, + { + "name": "probe: transform-globalNumber", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: Number}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'Number'."], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es5.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-globalParseInt", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: parseInt}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",parseInt]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-satisfiesFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function toNum(v: string) { return 1; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: toNum satisfies Function}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[183, 207]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-namespaceFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nnamespace NS { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: NS.f}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[196, 200]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-nsClash", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nimport * as u from './util';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: u.toNum}) x!: number;\n static ngAcceptInputType_x: string;\n}\n", + "util.ts": "export function toNum(v: string) { return 1; }\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in util.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-nsGeneric", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nimport * as u from './util';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: u.g}) x!: number;\n}\n", + "util.ts": "export function g(v: T) { return 1; }\n" + }, + "diagnostics": ["Input transform function cannot be generic Value is a reference to 'g'."], + "classes": {}, + "dtsImports": [], + "skip": "needs declarations from another file; oxc compiles one file at a time (imported consts, types and transforms are opaque)" + }, + { + "name": "probe: transform-coreNamespaceMembers", + "origin": "probe", + "files": { + "test.ts": "import * as core from '@angular/core';\n@core.Component({selector: 'c', template: '
'})\nexport class Cmp {\n @core.Input({transform: (v: string) => 1}) value!: number;\n @core.ViewChild('ref') ref: any;\n @core.Output() changed = new core.EventEmitter();\n @core.HostBinding('class.a') a = true;\n @core.HostListener('click') onClick() {}\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Cmp": { + "inputs": "{value:[2,\"value\",\"value\",(v)=>1]}", + "outputs": "{changed:\"changed\"}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery([\"ref\"],5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.ref=_t.first);}}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-foreignNamespaceMembers", + "origin": "probe", + "files": { + "test.ts": "import {Component, Input} from '@angular/core';\nimport * as foreign from 'foreign-decorators';\n@Component({selector: 'c', template: '
'})\nexport class Cmp {\n @Input() real!: string;\n @foreign.Input({transform: 5}) value!: string;\n @foreign.ViewChild('ref') ref: any;\n @foreign.Output() changed: any;\n @foreign.HostBinding('class.a') a = true;\n @foreign.HostListener('click') onClick() {}\n}\n", + "node_modules/foreign-decorators/index.d.ts": "export declare function Input(o?: any): PropertyDecorator;\nexport declare function Output(): PropertyDecorator;\nexport declare function ViewChild(s: string): PropertyDecorator;\nexport declare function HostBinding(s: string): PropertyDecorator;\nexport declare function HostListener(s: string): MethodDecorator;\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Cmp": { + "inputs": "{real:\"real\"}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-localObjectNamespaceMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nconst other = { Input: (o?: any) => (t: any, k: string) => {} };\n@Directive({selector: '[d]'})\nexport class Dir {\n @other.Input({transform: 5}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-staticPropNoInit", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass U { static c: (v: string) => number; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: U.c}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'c'."], + "diagnosticSpans": [[76, 108]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-staticSetter", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nclass U { static set c(v: any) {} }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: U.c}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'c'."], + "diagnosticSpans": [[76, 99]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-globalMathRound", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: Math.round}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[136, 146]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-globalJsonParse", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: JSON.parse}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[136, 146]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-globalString", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: String}) x!: number;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'String'."], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es5.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-globalIsNaN", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: isNaN}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",isNaN]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-globalEscape", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: escape}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",escape]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-globalEval", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: eval}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",eval]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-globalDecodeURI", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: decodeURI}) x!: number;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",decodeURI]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-globalNumberClash", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: Number}) x!: number;\n static ngAcceptInputType_x: string;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'Number'."], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es5.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-globalParseIntStatic", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: parseInt}) x!: number;\n static ngAcceptInputType_y: string;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",parseInt]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-instanceClash", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: (v: string) => 1}) x!: number;\n ngAcceptInputType_x: string;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsMathPI", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [Math.PI]})\nexport class Dir {}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value could not be determined statically." + ], + "diagnosticSpans": [[103, 112]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsMath", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [Math]})\nexport class Dir {}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is a reference to 'Math'." + ], + "diagnosticSpans": [[103, 109]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsNumberMax", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: Number.MAX_VALUE}]})\nexport class Dir {}\n" + }, + "diagnostics": [ + "Value at position 0 of @Directive.inputs array must have a \"name\" property Value could not be determined statically." + ], + "diagnosticSpans": [[103, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsObjKeys", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: [Object.keys]})\nexport class Dir {}\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value could not be determined statically." + ], + "diagnosticSpans": [[103, 116]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-constMathRound", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nconst r = Math.round;\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: r}) x!: number;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[158, 159]], + "classes": {}, + "dtsImports": [] + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From b6b3403672cdf6c9c11fc20cd959b5cc7d124fb0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 20:56:30 +0800 Subject: [PATCH 3/7] fix(directive): match ngtsc for transforms passed through helpers; find `@core.Input()` by its import Checked against @angular/compiler-cli 22.1.7 (probes are in the fixture): - A function expression passed to a same-file function (`make('x', (v: string) => v.length)`, or as a parameter's default) and returned as `{ transform }` is analyzable, as in ngtsc, instead of "Input transform must be a function". Named through the parameter (`transform: t`) it still isn't, like ngtsc. - `@Input(opts(...))` emits the transform the options resolve to, as written where the directive is compiled (`booleanAttribute`), and a transform there that uses the helper's parameters is reported like one in `inputs:`. - A transform written in a generic helper can't use the helper's type parameters: "Symbol must be exported ...", as ngtsc reports (the `.d.ts` would otherwise name a type that isn't in scope). - `@core.Input()` and the other namespaced member decorators are found from the import as written. With `resolved_imports` mapping `core` to a file path, the `@angular/core` check failed and the inputs and outputs disappeared from the definition. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/component/transform.rs | 24 ++- .../src/directive/decorator.rs | 49 ++++-- .../src/directive/evaluator.rs | 78 ++++++++- .../tests/decorator_metadata_ngtsc_test.rs | 72 ++++++--- .../fixtures/decorator_metadata_ngtsc.json | 148 ++++++++++++++++++ 5 files changed, 330 insertions(+), 41 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index ead82b667..2a2798183 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2537,12 +2537,26 @@ pub fn transform_angular_file( } // `@core.Input()` and the other member decorators through a namespace - // import of `@angular/core`. + // import of `@angular/core`, as written: `resolved_imports` may have + // pointed the import map's entry at another path. let _core_namespaces = crate::directive::CoreNamespaces::enter( - import_map - .keys() - .filter(|name| is_angular_core_namespace(&import_map, name)) - .map(ToString::to_string) + parser_ret + .program + .body + .iter() + .filter_map(|stmt| match stmt { + Statement::ImportDeclaration(import) if import.source.value == "@angular/core" => { + import.specifiers.as_ref() + } + _ => None, + }) + .flatten() + .filter_map(|spec| match spec { + ImportDeclarationSpecifier::ImportNamespaceSpecifier(ns) => { + Some(ns.local.name.to_string()) + } + _ => None, + }) .collect(), ); diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index aa75e492f..c3895bada 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -695,19 +695,40 @@ fn io_error(field: &str, message: impl FnOnce() -> String, value: &Value<'_>) -> /// The same for a transform written in a namespace that uses the namespace's /// declarations (`transform: fn`, with `fn` declared in the namespace), for /// which ngtsc emits the bare names. -fn scoped_transform_error(input: &str, expr: &Expression<'_>, consts: &StringConsts<'_>) -> String { +/// +/// `subject` is where the transform is declared: `@Directive.inputs` or `@Input`. +fn scoped_transform_error( + subject: &str, + input: &str, + expr: &Expression<'_>, + consts: &StringConsts<'_>, +) -> String { match consts.scope().namespace_used_by(expr) { Some(namespace) => format!( - "@Directive.inputs: the transform of \"{input}\" uses a declaration of namespace \ + "{subject}: the transform of \"{input}\" uses a declaration of namespace \ {namespace}, which isn't in scope outside it. OXC can't emit it there." ), None => format!( - "@Directive.inputs: the transform of \"{input}\" uses a parameter of the function \ + "{subject}: the transform of \"{input}\" uses a parameter of the function \ it's written in. OXC can't emit it outside that function." ), } } +/// Whether [`transform_expression`] has no expression to emit for a transform +/// it was given, because it uses the parameters of the function it's written +/// in (see [`scoped_transform_error`]). +fn is_out_of_scope(transform: &Prop<'_>, consts: &StringConsts<'_>) -> bool { + transform.expr.is_some() && transform.origin.is_none() && !is_named_function(transform, consts) +} + +/// Whether a transform is emitted as the name of the same-file function it +/// resolves to (see [`transform_expression`]). +fn is_named_function(transform: &Prop<'_>, consts: &StringConsts<'_>) -> bool { + matches!(&transform.value, Value::Reference { name, kind: RefKind::Function(function, _) } + if consts.scope().is_top_level_function(name, function)) +} + /// Parse `inputs:` / `outputs:` from a decorator metadata object. /// /// Reference: `parseInputsArray` / `parseOutputsArray` in @@ -867,8 +888,10 @@ fn parse_input_object<'a>( let error = transform_error(transform, Some(position), name, class, consts.scope(), span) .or_else(|| match transform { - Prop { expr: Some(written), origin: None, .. } if expr.is_none() => { - Some((scoped_transform_error(name, written, consts), span)) + Prop { expr: Some(written), .. } if is_out_of_scope(transform, consts) => { + let message = + scoped_transform_error("@Directive.inputs", name, written, consts); + Some((message, span)) } _ => None, }); @@ -901,9 +924,7 @@ pub(crate) fn transform_expression<'a>( consts: &StringConsts<'a>, ) -> Option> { match &transform.value { - Value::Reference { name, kind: RefKind::Function(function, _) } - if consts.scope().is_top_level_function(name, function) => - { + Value::Reference { name, .. } if is_named_function(transform, consts) => { Some(OutputAstBuilder::variable(allocator, Ident::from(allocator.alloc_str(name)))) } _ => convert_oxc_expression(allocator, transform.origin?, source_text), @@ -1045,7 +1066,17 @@ pub fn decorator_io_errors<'a>( let options = evaluator.evaluate(options); if let Some(transform) = options.prop("transform") { let error = - transform_error(transform, None, &name, class, consts.scope(), span); + transform_error(transform, None, &name, class, consts.scope(), span) + .or_else(|| match transform { + Prop { expr: Some(written), .. } + if is_out_of_scope(transform, consts) => + { + let message = + scoped_transform_error("@Input", &name, written, consts); + Some((message, span)) + } + _ => None, + }); if error.is_some() { return error; } diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 58fad4faf..38f6a3f56 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -602,6 +602,24 @@ impl<'a> FileScope<'a> { pub(crate) fn import(&self, name: &str) -> Option> { self.imports.get(name).copied() } + + /// The type parameters of the innermost same-file function or static + /// method (the functions the evaluator calls) whose body contains `span`. + fn enclosing_type_parameters(&self, span: Span) -> Option<&'a TSTypeParameterDeclaration<'a>> { + let methods = self.classes.values().flat_map(|class| { + class.body.body.iter().filter_map(|el| match el { + ClassElement::MethodDefinition(m) if m.r#static => Some(&*m.value), + _ => None, + }) + }); + self.functions + .values() + .map(|(function, _)| *function) + .chain(methods) + .filter(|f| f.body.as_ref().is_some_and(|body| body.span.contains_inclusive(span))) + .min_by_key(|f| f.span.size()) + .and_then(|f| f.type_parameters.as_deref()) + } } /// The names a binding pattern declares, each with its path into the initializer. @@ -1273,10 +1291,19 @@ impl<'s, 'a> Evaluator<'s, 'a> { let Some(key) = self.property_key(&p.key, p.computed, depth, frame) else { return Value::Dynamic; }; - let value = match &p.value { - Expression::ArrowFunctionExpression(f) => Value::Function(FnDef::Arrow(f)), - Expression::FunctionExpression(f) => Value::Function(FnDef::Function(f)), - value => self.eval(value, depth, frame), + let value = match (&p.value, function_value(&p.value)) { + (_, Some(function)) => function, + // `{ transform }` reads a parameter as it is: ngtsc + // looks a shorthand up by its declaration, which + // doesn't make a function expression opaque the way + // naming it does (see `identifier`). + (Expression::Identifier(id), None) if p.shorthand => { + match frame.bindings.get(id.name.as_str()) { + Some(binding) => binding.value.clone(), + None => self.eval(&p.value, depth, frame), + } + } + (value, None) => self.eval(value, depth, frame), }; let origin = self.origin(&p.value, frame); props.push(Prop { key, value, expr: Some(&p.value), origin }); @@ -1439,7 +1466,13 @@ impl<'s, 'a> Evaluator<'s, 'a> { fn identifier(&self, name: &'a str, depth: u16, frame: &Frame<'a>) -> Value<'a> { if let Some(binding) = frame.bindings.get(name) { - return binding.value.clone(); + return match &binding.value { + // A function expression passed as an argument isn't + // analyzable through the parameter's name (ngtsc wraps it in + // a dynamic value for that identifier). + Value::Function(_) => Value::Dynamic, + value => value.clone(), + }; } let scope = self.consts.scope(); // Inside a namespace, its declarations (and its enclosing ones') come first. @@ -1825,8 +1858,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { // Each argument's value, and where it was written. let mut args = std::vec::Vec::new(); let mut origins = std::vec::Vec::new(); + let mut spread_seen = false; for arg in &call.arguments { if let Argument::SpreadElement(spread) = arg { + spread_seen = true; let values = self.spread(&spread.argument, depth, frame); let known = self .element_origins(&spread.argument, frame) @@ -1835,7 +1870,14 @@ impl<'s, 'a> Evaluator<'s, 'a> { args.extend(values); } else { let expr = arg.to_expression(); - args.push(self.eval(expr, depth, frame)); + let value = self.eval(expr, depth, frame); + // A function expression passed as an argument (or as a default + // value) stays analyzable in the parameter, as ngtsc binds the + // value as it is, up to the first spread. + args.push(match function_value(expr) { + Some(function) if !spread_seen => function, + _ => value, + }); origins.push(self.origin(expr, frame)); } } @@ -1848,7 +1890,9 @@ impl<'s, 'a> Evaluator<'s, 'a> { let binding = match args.get(i + offset) { None | Some(Value::Undefined) if param.initializer.is_some() => { param.initializer.as_ref().map(|init| { - Binding::new(self.eval(init, depth, &scope), self.origin(init, &scope)) + let value = + function_value(init).unwrap_or_else(|| self.eval(init, depth, &scope)); + Binding::new(value, self.origin(init, &scope)) }) } arg => arg.map(|value| { @@ -2140,9 +2184,17 @@ pub(crate) fn transform_error<'a>( let in_class = matches!(value, Value::Function(_)) && class.body.span.contains_inclusive(def.span()); let class_params = class.type_parameters.as_deref().filter(|_| in_class); + // One written in a function the metadata calls sees that + // function's type parameters. + let helper_params = match value { + Value::Function(_) => scope.enclosing_type_parameters(def.span()), + _ => None, + }; let mut check = UnexportedType { scope, type_params: std::vec::Vec::new(), found: false }; - check.with_params(class_params, |check| check.visit_ts_type(ty)); + check.with_params(helper_params, |check| { + check.with_params(class_params, |check| check.visit_ts_type(ty)); + }); check.found.then(|| { ( "Symbol must be exported in order to be used as the type of an Input transform function" @@ -2296,6 +2348,16 @@ impl<'a> Visit<'a> for Mentions<'a> { } } +/// An arrow or function expression written in place, which ngtsc can analyze +/// as an input transform. +fn function_value<'a>(expr: &'a Expression<'a>) -> Option> { + match expr { + Expression::ArrowFunctionExpression(f) => Some(Value::Function(FnDef::Arrow(f))), + Expression::FunctionExpression(f) => Some(Value::Function(FnDef::Function(f))), + _ => None, + } +} + /// Bind a parameter: a destructured parameter's names aren't evaluated. fn bind<'a>(frame: &mut Frame<'a>, pattern: &'a BindingPattern<'a>, binding: Binding<'a>) { if let BindingPattern::BindingIdentifier(id) = pattern { diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 48b69456a..7b2a7ca15 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -19,7 +19,7 @@ //! merged safely into bundled declaration files; `i0` (`@angular/core`) is //! always imported, so those types are kept. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use oxc_allocator::Allocator; use oxc_angular_compiler::{TransformOptions, TransformResult, transform_angular_file}; @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 496, "fixtures compared"); + assert_eq!(compared, 506, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -380,25 +380,32 @@ export class Dir {} /// A transform returned by a function the metadata calls is emitted where the /// directive is compiled, outside that function. A parameter becomes the /// argument it was passed (the snapshot's `scope-*` probes); anything else that -/// uses the function's parameters, `this` or `arguments` would mean something -/// else there, or nothing. ngtsc 22.1.7 emits `(v) => v + name` as written -/// (`name` is then `window.name`) and `booleanAttribute` for `o.t` (the name -/// the argument was first given); oxc reports these instead. +/// uses the function's parameters would mean something else there, or nothing. +/// ngtsc 22.1.7 emits `(v) => v + name` as written (`name` is then +/// `window.name`) and `booleanAttribute` for `o.t` (the name the argument was +/// first given); oxc reports these instead. (`this` and `arguments` there +/// aren't analyzable, so ngtsc rejects those itself: `scope-this`, +/// `scope-arguments`.) #[test] fn transforms_using_the_parameters_of_a_called_function_are_reported() { let cases = [ ( "function make(name: string) { return [{ name, transform: (v: string) => v + name }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(o: any) { return [{ name: 'x', transform: o.t }]; }", + "inputs: make({ t: booleanAttribute })", + "@Directive.inputs", "make({ t: booleanAttribute })", ), - ("class H { static make() { return [{ name: 'x', transform: this.t }]; } }", "H.make()"), ( - "function make() { return [{ name: 'x', transform: arguments[0] }]; }", - "make(booleanAttribute)", + "function opts(n: string) { return { transform: (v: string) => v + n }; }", + "", + "@Input", + "opts('a')", ), // Scoped like JavaScript: only a nested non-arrow function has its own // `arguments`, and a name declared in a block isn't in scope after it @@ -452,22 +459,21 @@ function make(name: string) { return [{ name: 'x', transform: (v: string) => id< "make('x')", ), ]; - for (helper, inputs) in cases { + for (helper, meta, subject, span) in cases { + let member = if meta.is_empty() { "@Input(opts('a')) x: any;" } else { "x: any;" }; let source = format!( - "import {{Directive, booleanAttribute}} from '@angular/core'; + "import {{Directive, Input, booleanAttribute}} from '@angular/core'; {helper} -@Directive({{selector: '[d]', inputs: {inputs}}}) -export class Dir {{ x: any; }} +@Directive({{selector: '[d]', {meta}}}) +export class Dir {{ {member} }} " ); let result = transform(&source); - let message = "@Directive.inputs: the transform of \"x\" uses a parameter of the \ - function it's written in. OXC can't emit it outside that function."; - assert_eq!( - errors(&result, &source), - vec![(message.to_string(), inputs.to_string())], - "{helper}" + let message = format!( + "{subject}: the transform of \"x\" uses a parameter of the function it's \ + written in. OXC can't emit it outside that function." ); + assert_eq!(errors(&result, &source), vec![(message, span.to_string())], "{helper}"); } } @@ -792,3 +798,31 @@ import * as NS from 'foreign-decorators'; } } } + +/// `resolved_imports` points an imported name at the file that declares it +/// (past a barrel). It doesn't change which module the import is from, so +/// `@core.Input()` through `import * as core from '@angular/core'` is still +/// Angular's when `core` is mapped. +#[test] +fn namespaced_member_decorators_ignore_resolved_import_paths() { + let source = "import {Component} from '@angular/core'; +import * as core from '@angular/core'; +@Component({selector: 'c', template: ''}) +export class Cmp { + @core.Input() x: any; + @core.Output() y: any; +} +"; + let options = TransformOptions { + resolved_imports: Some(HashMap::from([( + "core".to_string(), + "../node_modules/@angular/core/fesm2022/core.mjs".to_string(), + )])), + ..TransformOptions::default() + }; + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, "test.ts", source, Some(&options), None); + let code = strip(&result.code); + assert!(code.contains(r#"inputs:{x:"x"}"#), "{}", result.code); + assert!(code.contains(r#"outputs:{y:"y"}"#), "{}", result.code); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index ba8c9e750..0bc8b7982 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -5874,6 +5874,154 @@ "classes": {}, "dtsImports": [] }, + { + "name": "probe: scope-shadow", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nexport function toBool(v: unknown): boolean { return !!v; }\nfunction make(name: string, toBool: any) { return [{ name, transform: toBool }]; }\n@Directive({ selector: '[d]', inputs: make('x', (v: string) => v.length) })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[173, 179]] + }, + { + "name": "probe: scope-paramMember", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nexport function toBool(v: unknown): boolean { return !!v; }\nconst T = { toBool };\nfunction make(name: string, transform: any) { return [{ name, transform }]; }\n@Directive({ selector: '[d]', inputs: make('x', T.toBool) })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",toBool]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: scope-memberOpts", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input, booleanAttribute } from '@angular/core';\nfunction opts(t: any) { return { transform: t }; }\n@Directive({ selector: '[d]' })\nexport class D { @Input(opts(booleanAttribute)) x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: scope-genericHelper", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nfunction make(n: string) { return [{ name: n, transform: (v: T) => v }]; }\n@Directive({ selector: '[d]', inputs: make('x') })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[107, 108]] + }, + { + "name": "probe: scope-objParamFn", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nexport function toBool(v: unknown): boolean { return !!v; }\nfunction make(o: any) { return [{ name: 'x', transform: o.t }]; }\n@Directive({ selector: '[d]', inputs: make({ t: toBool }) })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",toBool]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: scope-paramArrowNamed", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nfunction make(name: string, t: any) { return [{ name, transform: t }]; }\n@Directive({ selector: '[d]', inputs: make('x', (v: string) => v.length) })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[108, 109]] + }, + { + "name": "probe: scope-memberOptsArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nfunction opts(transform: any) { return { transform }; }\n@Directive({ selector: '[d]' })\nexport class D { @Input(opts((v: string) => v.length)) x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: scope-this", + "origin": "probe", + "files": { + "test.ts": "import { Directive, booleanAttribute } from '@angular/core';\nclass H { static make() { return [{ name: 'x', transform: this.t }]; } }\n@Directive({ selector: '[d]', inputs: H.make() })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[119, 125]] + }, + { + "name": "probe: scope-arguments", + "origin": "probe", + "files": { + "test.ts": "import { Directive, booleanAttribute } from '@angular/core';\nfunction make() { return [{ name: 'x', transform: arguments[0] }]; }\n@Directive({ selector: '[d]', inputs: make(booleanAttribute) })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[111, 123]] + }, + { + "name": "probe: scope-defaultArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive } from '@angular/core';\nfunction make(n: string, transform: any = (v: string) => v.length) { return [{ name: n, transform }]; }\n@Directive({ selector: '[d]', inputs: make('x') })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From 04933e680a2b097041362c8c9bb3b56482b1c94a Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 30 Sep 2026 21:09:04 +0800 Subject: [PATCH 4/7] fix(directive): accept a shorthand transform for a const function expression, like ngtsc `const transform = (v: string) => v.length;` with `inputs: [{name: 'x', transform}]` or `@Input({transform})` was rejected ("Input transform must be a function"); ngtsc 22.1.7 accepts it and emits the arrow. ngtsc looks a shorthand property up by its declaration (`visitDeclaration`), which reads the variable's initializer as is, while naming the variable (`transform: transform`, `transform: t`) wraps it in a dynamic value for that identifier, which it rejects. oxc now does the same: - A shorthand for a top-level variable whose initializer is an arrow or function expression (`const`/`let`/`var`, exported, typed, or destructured from an object or array literal) is checked as that function, and emits it. So are the objects it's in, spread or not, and `@Input(OPTS)`. - Named, it's still "must be a function", and so is an initializer in parentheses, behind `as`, or naming another variable; their errors point at the initializer, as ngtsc's do. Probes of each form are in the fixture (`shorthand-*`). Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/evaluator.rs | 115 ++++- .../tests/decorator_metadata_ngtsc_test.rs | 24 +- .../fixtures/decorator_metadata_ngtsc.json | 442 ++++++++++++++++++ 3 files changed, 564 insertions(+), 17 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 38f6a3f56..ff0a01eff 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -603,6 +603,45 @@ impl<'a> FileScope<'a> { self.imports.get(name).copied() } + /// The expression the top-level variable `name` is initialized with: its + /// initializer or, destructured from an object or array literal, the part + /// it binds (`transform` in `const { transform } = { transform: f }` is `f`). + fn initializer(&self, name: &str) -> Option<&'a Expression<'a>> { + let Variable::Init(init, path) = self.variables.get(name)? else { return None }; + let mut init: &'a Expression<'a> = init; + for key in path { + init = match (key, init.without_parentheses()) { + (PathKey::Key(k), Expression::ObjectExpression(obj)) => { + // A spread could replace the property: only a literal one counts. + let spread = |p: &ObjectPropertyKind<'_>| { + matches!(p, ObjectPropertyKind::SpreadProperty(_)) + }; + if obj.properties.iter().any(spread) { + return None; + } + obj.properties.iter().rev().find_map(|p| match p { + ObjectPropertyKind::ObjectProperty(p) + if !p.computed && p.key.static_name().is_some_and(|n| n == *k) => + { + Some(&p.value) + } + _ => None, + })? + } + // Up to the element, every one must be a plain expression. + (PathKey::Index(i), Expression::ArrayExpression(arr)) => { + let before = arr.elements.get(..=*i)?; + if !before.iter().all(ArrayExpressionElement::is_expression) { + return None; + } + before[*i].as_expression()? + } + _ => return None, + }; + } + Some(init) + } + /// The type parameters of the innermost same-file function or static /// method (the functions the evaluator calls) whose body contains `span`. fn enclosing_type_parameters(&self, span: Span) -> Option<&'a TSTypeParameterDeclaration<'a>> { @@ -1298,15 +1337,33 @@ impl<'s, 'a> Evaluator<'s, 'a> { // doesn't make a function expression opaque the way // naming it does (see `identifier`). (Expression::Identifier(id), None) if p.shorthand => { - match frame.bindings.get(id.name.as_str()) { + let name = id.name.as_str(); + match frame.bindings.get(name) { Some(binding) => binding.value.clone(), - None => self.eval(&p.value, depth, frame), + None => match self.slot(frame.block, name) { + Some(slot) => self.stored(slot, depth), + None => self.eval(&p.value, depth, frame), + }, } } (value, None) => self.eval(value, depth, frame), }; - let origin = self.origin(&p.value, frame); - props.push(Prop { key, value, expr: Some(&p.value), origin }); + let mut expr = &p.value; + let mut origin = self.origin(&p.value, frame); + // A shorthand for a variable stands for its initializer + // (what ngtsc emits, or points its error at). + if let Expression::Identifier(id) = &p.value + && p.shorthand + && !frame.bindings.contains_key(id.name.as_str()) + && let Some((block, name)) = self.slot(frame.block, id.name.as_str()) + && let Some(init) = + self.consts.scope().declarations(block).initializer(name) + && matches!(value, Value::Function(_) | Value::Dynamic) + { + expr = init; + origin = self.origin(init, &Frame::at(block)); + } + props.push(Prop { key, value, expr: Some(expr), origin }); } ObjectPropertyKind::SpreadProperty(spread) => { match self.eval(&spread.argument, depth, frame) { @@ -1511,7 +1568,13 @@ impl<'s, 'a> Evaluator<'s, 'a> { let file = self.consts.scope(); let scope = file.declarations(block); if scope.variables.contains_key(name) || scope.enums.contains_key(name) { - return Some(self.stored((block, name), depth)); + return Some(match self.stored((block, name), depth) { + // A function expression isn't analyzable through the + // variable's name (ngtsc wraps it in a dynamic value for that + // identifier). + Value::Function(_) => Value::Dynamic, + value => value, + }); } if let Some((function, overloads)) = scope.functions.get(name) { let kind = RefKind::Function(function, *overloads); @@ -1648,12 +1711,28 @@ impl<'s, 'a> Evaluator<'s, 'a> { let (block, name) = slot; match variable { // Declarations don't see the caller's parameters. + // A function expression initializer stays analyzable (ngtsc visits + // it as is); naming the variable makes it opaque (see + // `identifier`), but a shorthand property doesn't (see `object`). Variable::Init(init, path) => { + let scope = self.consts.scope().declarations(block); + if let Some(function) = scope.initializer(name).and_then(function_value) { + return function; + } let mut value = self.eval(init, depth, &Frame::at(block)); for key in path { value = match key { PathKey::Index(i) => self.member(value, Key::Num(*i as f64), depth), - PathKey::Key(k) => self.member(value, Key::Str(k), depth), + // Destructuring reads a property as it is, so a + // function expression there stays analyzable too. + PathKey::Key(k) => match value { + Value::Object(props) => props + .into_iter() + .rev() + .find(|p| p.key == *k) + .map_or(Value::Undefined, |p| p.value), + value => self.member(value, Key::Str(k), depth), + }, PathKey::Unknown => Value::Dynamic, }; if matches!(value, Value::Dynamic) { @@ -1858,6 +1937,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { // Each argument's value, and where it was written. let mut args = std::vec::Vec::new(); let mut origins = std::vec::Vec::new(); + // A function expression passed as an argument (or as a default value) + // stays analyzable in the parameter it's bound to, as ngtsc binds the + // value as it is; but not after a spread, or in a rest parameter. + let mut functions = std::vec::Vec::new(); let mut spread_seen = false; for arg in &call.arguments { if let Argument::SpreadElement(spread) = arg { @@ -1870,14 +1953,10 @@ impl<'s, 'a> Evaluator<'s, 'a> { args.extend(values); } else { let expr = arg.to_expression(); - let value = self.eval(expr, depth, frame); - // A function expression passed as an argument (or as a default - // value) stays analyzable in the parameter, as ngtsc binds the - // value as it is, up to the first spread. - args.push(match function_value(expr) { - Some(function) if !spread_seen => function, - _ => value, - }); + if !spread_seen { + functions.push(function_value(expr)); + } + args.push(self.eval(expr, depth, frame)); origins.push(self.origin(expr, frame)); } } @@ -1895,8 +1974,12 @@ impl<'s, 'a> Evaluator<'s, 'a> { Binding::new(value, self.origin(init, &scope)) }) } - arg => arg.map(|value| { - Binding::new(value.clone(), origins.get(i + offset).copied().flatten()) + arg => arg.map(|arg| { + let function = functions.get(i + offset).cloned().flatten(); + Binding::new( + function.unwrap_or_else(|| arg.clone()), + origins.get(i + offset).copied().flatten(), + ) }), }; let binding = binding.unwrap_or(Binding::new(Value::Undefined, None)); diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 7b2a7ca15..99bf6c0f5 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 506, "fixtures compared"); + assert_eq!(compared, 537, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -412,22 +412,32 @@ fn transforms_using_the_parameters_of_a_called_function_are_reported() { // (the snapshot's `scope-shadowed*` probes are the names that are). ( "function make(name: string) { return [{ name, transform: (v: string) => arguments.length }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name, transform: (v: string) => { { const name = v; } return name; } }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name, transform: (v: string) => ({ [name]: v }) }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name, transform: (v: string) => { switch (name) { case 'x': let name = v; return name; } return v; } }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name, transform: function (v: string) { return (() => name)(); } }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), // Types are erased (the snapshot's `scope-typeOnly-*` probes), but the @@ -435,27 +445,39 @@ fn transforms_using_the_parameters_of_a_called_function_are_reported() { // `satisfies` and `x`, a parameter's default and a call's arguments. ( "function make(name: string) { return [{ name: 'x', transform: (v: string) => name! }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name: 'x', transform: (v: string) => name as typeof name }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name: 'x', transform: (v: string) => name satisfies string }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name: 'x', transform: (v: string) => name }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function make(name: string) { return [{ name: 'x', transform: (v: string, d: typeof name = name) => v }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ( "function id(v: T) { return v; } function make(name: string) { return [{ name: 'x', transform: (v: string) => id(name) }]; }", + "inputs: make('x')", + "@Directive.inputs", "make('x')", ), ]; diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 0bc8b7982..5870bba78 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -6022,6 +6022,448 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "probe: shorthand-constArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-constArrowNamedSame", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform: transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[156, 165]] + }, + { + "name": "probe: shorthand-constArrowNamedOther", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst t = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform: t }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[148, 149]] + }, + { + "name": "probe: shorthand-constFunctionExpression", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = function (v: string) { return v.length; };\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",function(v){returnv.length;}]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-letArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nlet transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-constArrowTyped", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform: (v: string) => number = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-constArrowParenthesized", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = ((v: string) => v.length);\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[68, 93]] + }, + { + "name": "probe: shorthand-constArrowAs", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = ((v: string) => v.length) as any;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[68, 100]] + }, + { + "name": "probe: shorthand-constChain", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst a = (v: string) => v.length;\nconst transform = a;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[103, 104]] + }, + { + "name": "probe: shorthand-inConstObject", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\nconst X = { name: 'x', transform };\n@Directive({ selector: '[d]', inputs: [X] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-constArrowUntyped", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v) => v;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[68, 76]] + }, + { + "name": "probe: shorthand-constArrowGeneric", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: T) => v;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform function cannot be generic Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[68, 82]] + }, + { + "name": "probe: shorthand-objectDestructured", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst { transform } = { transform: (v: string) => v.length };\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-exportedConstArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nexport const transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-varRedeclared", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nvar transform = (v: string) => v.length;\nvar transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-spreadConstObject", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\nconst B = { transform };\n@Directive({ selector: '[d]', inputs: [{ name: 'x', ...B }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-constArrowUnexportedType", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\ninterface Foo { a: string }\nconst transform = (v: Foo) => 1;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[100, 103]] + }, + { + "name": "probe: shorthand-constArrowClash", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; static ngAcceptInputType_x: string; }\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[68, 91]] + }, + { + "name": "probe: shorthand-arrayDestructured", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst [transform] = [(v: string) => v.length];\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-objectDestructuredNamed", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst t = (v: string) => v.length;\nconst { transform } = { transform: t };\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[120, 121]] + }, + { + "name": "probe: shorthand-nestedDestructured", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst { o: { transform } } = { o: { transform: (v: string) => v.length } };\n@Directive({ selector: '[d]', inputs: [{ name: 'x', transform }] })\nexport class D { x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-memberConstArrow", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\n@Directive({ selector: '[d]' })\nexport class D { @Input({ transform }) x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: shorthand-memberConstArrowNamedSame", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\n@Directive({ selector: '[d]' })\nexport class D { @Input({ transform: transform }) x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[162, 171]] + }, + { + "name": "probe: shorthand-memberConstArrowNamedOther", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst t = (v: string) => v.length;\n@Directive({ selector: '[d]' })\nexport class D { @Input({ transform: t }) x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[154, 155]] + }, + { + "name": "probe: shorthand-memberOptionsConst", + "origin": "probe", + "files": { + "test.ts": "import { Directive, Input } from '@angular/core';\nconst transform = (v: string) => v.length;\nconst OPTS = { transform };\n@Directive({ selector: '[d]' })\nexport class D { @Input(OPTS) x: any; }\n" + }, + "diagnostics": [], + "classes": { + "D": { + "inputs": "{x:[2,\"x\",\"x\",(v)=>v.length]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [], + "diagnosticSpans": [] + }, + { + "name": "probe: scope-spreadConstFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nexport function toBool(v: string) { return !!v; }\nfunction make(name: string, t: any) { return [{name, transform: t}]; }\nconst args = ['x', (v: string) => v.trim()] as const;\n@Directive({selector: '[d]', inputs: make(...args)})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[173, 174]] + }, + { + "name": "probe: scope-spreadFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nfunction make(name: string, t: any) { return [{name, transform: t}]; }\n@Directive({selector: '[d]', inputs: make('x', ...[(v: string) => v.trim()])})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 124]] + }, + { + "name": "probe: scope-restFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nfunction make(...a: any[]) { return [{name: a[0], transform: a[1]}]; }\n@Directive({selector: '[d]', inputs: make('x', (v: string) => v.trim())})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[120, 124]] + }, + { + "name": "probe: scope-restAfterParamFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nfunction make(name: string, ...ts: any[]) { return [{name, transform: ts[0]}]; }\n@Directive({selector: '[d]', inputs: make('x', (v: string) => v.trim())})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[129, 134]] + }, + { + "name": "probe: scope-spreadRestThroughFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nfunction make(name: string, t: any) { return [{name, transform: t}]; }\nfunction outer(...a: any[]) { return make(...a); }\n@Directive({selector: '[d]', inputs: outer('x', (v: string) => v.trim())})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 124]] + }, + { + "name": "probe: scope-functionAfterSpread", + "origin": "probe", + "files": { + "test.ts": "import {Directive, booleanAttribute} from '@angular/core';\nfunction make(name: string, t: any) { return [{name, transform: t}]; }\nconst pre = ['x'];\n@Directive({selector: '[d]', inputs: make(...pre, (v: string) => v.trim())})\nexport class D { x: any; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "diagnosticSpans": [[123, 124]] + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From 8a882326464a74b112a054fd0bf9db9ed8ca8a5c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 01:15:27 +0800 Subject: [PATCH 5/7] fix(directive): report `@Input` options imported from another module `@Input(OPTS)`, `@Input(ns.OPTS)`, `@Input({...OPTS})` and an imported `alias` / `required` compiled the input without its alias, `required` flag or transform, with no error. ngtsc reads the other file (with `export const OPTS = {alias: 'y'}` it compiles the input as `y`); oxc can't, so it now reports that the options depend on another module, as it already does for `inputs:` entries. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/decorator.rs | 29 ++++++++-- .../tests/decorator_metadata_ngtsc_test.rs | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index c3895bada..38d2092f2 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -675,18 +675,28 @@ pub(super) fn config_property<'a>( }) } -/// ngtsc's error for `value` in `@Directive.{field}`, or, when the value comes -/// from another module, why oxc can't tell (ngtsc would read that file). -fn io_error(field: &str, message: impl FnOnce() -> String, value: &Value<'_>) -> String { +/// ngtsc's error for a `value` of the wrong type (`message` and the line +/// describing the value), or, when the value comes from another module, why oxc +/// can't tell: ngtsc would read that file, and `subject` depends on it. +pub(super) fn value_error( + subject: &str, + message: impl FnOnce() -> String, + value: &Value<'_>, +) -> String { match value { Value::Reference { name, .. } if value.is_import() => format!( - "@Directive.{field} depends on '{name}', which is imported from another module. \ + "{subject} depends on '{name}', which is imported from another module. \ OXC compiles one file at a time and cannot evaluate values from other files." ), _ => format!("{}{}", message(), value.wrong_type_suffix()), } } +/// [`value_error`] for `value` in `@Directive.{field}`. +fn io_error(field: &str, message: impl FnOnce() -> String, value: &Value<'_>) -> String { + value_error(&format!("@Directive.{field}"), message, value) +} + /// oxc's error for a transform written inside a function that the metadata /// calls, using that function's parameters (`transform: (v) => v + name`), so /// it would mean something else, or nothing, where the metadata is compiled. @@ -1064,6 +1074,17 @@ pub fn decorator_io_errors<'a>( if let Some(options) = options { let span = options.span(); let options = evaluator.evaluate(options); + // ngtsc reads imported options (or an imported alias or + // `required`) from their file; oxc can't, and compiling the + // input without them would be a different binding. + let imported = std::iter::once(&options) + .chain( + ["alias", "required"].iter().filter_map(|k| Some(&options.prop(k)?.value)), + ) + .find(|value| value.is_import()); + if let Some(value) = imported { + return Some((value_error("@Input", String::new, value), span)); + } if let Some(transform) = options.prop("transform") { let error = transform_error(transform, None, &name, class, consts.scope(), span) diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 99bf6c0f5..430db51a5 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -377,6 +377,60 @@ export class Dir {} assert!(strip(&result.code).contains(r#"inputs:{x:[2,"x","x",fn]}"#), "{}", result.code); } +/// The same for an `@Input(...)` argument: ngtsc 22.1.7 reads these from +/// `./shared` (with `export const OPTS = {alias: 'y'}`, it compiles the input +/// as `y`). oxc can't, so it reports them instead of compiling the input +/// without its alias, `required` flag or transform. +#[test] +fn input_decorator_options_imported_from_another_module_are_reported() { + let cases = [ + ("@Input(OPTS) x: any;", "OPTS", "OPTS"), + ("@Input((OPTS)) x: any;", "OPTS", "(OPTS)"), + ("@Input(NAME) x: any;", "NAME", "NAME"), + ("@Input(ns.OPTS) x: any;", "OPTS", "ns.OPTS"), + ("@Input({...OPTS}) x: any;", "OPTS", "{...OPTS}"), + ("@Input({alias: 'z', ...OPTS}) x: any;", "OPTS", "{alias: 'z', ...OPTS}"), + ("@Input(LOCAL) x: any;", "OPTS", "LOCAL"), + ("@Input({alias: NAME}) x: any;", "NAME", "{alias: NAME}"), + ("@Input({required: REQ}) x: any;", "REQ", "{required: REQ}"), + ("@Input(OPTS) set x(v: any) {}", "OPTS", "OPTS"), + ]; + for (member, name, span) in cases { + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +import {{OPTS, NAME, REQ}} from './shared'; +import * as ns from './shared'; +const LOCAL = OPTS; +@Directive({{selector: '[d]'}}) +export class Dir {{ + {member} +}} +" + ); + let message = format!( + "@Input depends on '{name}', which is imported from another module. \ + OXC compiles one file at a time and cannot evaluate values from other files." + ); + assert_eq!( + errors(&transform(&source), &source), + vec![(message, span.to_string())], + "{member}" + ); + } + + // An imported transform is a reference: nothing to evaluate. + let source = "import {Directive, Input} from '@angular/core'; +import {fn} from './shared'; +@Directive({selector: '[d]'}) +export class Dir { + @Input({alias: 'y', transform: fn}) x: any; +} +"; + let result = transform(source); + assert!(errors(&result, source).is_empty(), "{:?}", errors(&result, source)); + assert!(strip(&result.code).contains(r#"inputs:{x:[2,"y","x",fn]}"#), "{}", result.code); +} + /// A transform returned by a function the metadata calls is emitted where the /// directive is compiled, outside that function. A parameter becomes the /// argument it was passed (the snapshot's `scope-*` probes); anything else that From 5a0f916053087b504e724139154efaf61a0212a0 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 02:08:24 +0800 Subject: [PATCH 6/7] fix(directive): assume globals declared outside the file are transform functions ngtsc resolves a global like the DOM's `atob` or a project's `declare function f(v: string): number` to its declaration, and accepts it as an input transform. oxc only knew the ES2022 lib, so it reported "Input transform must be a function" for these. A name the file doesn't declare or import, and that isn't an ES2022 lib global, is now assumed to be a function, like an imported transform: it's emitted as written and only the `ngAcceptInputType_` clash is checked. Everywhere else it stays dynamic, as before, and so does a shorthand `{ transform }` naming one, which ngtsc never accepts. `Intl` and `Reflect` join the known lib globals (so they keep ngtsc's "reference to" error), `globalThis` stays dynamic, and a namespace or `import x = ...` declared in the file isn't taken for a global. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/evaluator.rs | 69 ++- .../tests/decorator_metadata_ngtsc_test.rs | 65 ++- .../fixtures/decorator_metadata_ngtsc.json | 547 ++++++++++++++++++ 3 files changed, 660 insertions(+), 21 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index ff0a01eff..28cf19c6d 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -63,6 +63,9 @@ pub(crate) struct FileScope<'a> { /// ngtsc's diagnostics point at it: the whole statement (with `export`) for /// a function or class, the declarator (`x: T`) for a variable. declaration_spans: HashMap<&'a str, Span>, + /// Namespaces and `import x = ...` aliases: declared in the file, but not + /// read by the evaluator (their values are dynamic). + unread: HashSet<&'a str>, /// Import-equals aliases (`import A = NS.T`, `export import A = NS`, /// `import A = require('m')`): what each stands for. aliases: HashMap<&'a str, &'a TSModuleReference<'a>>, @@ -210,8 +213,15 @@ impl<'a> FileScope<'a> { None => continue, }, }; - if let Declaration::TSNamespaceDeclaration(ns) = decl { - scope.namespace(ns, None, exported, false); + match decl { + Declaration::TSNamespaceDeclaration(ns) => { + scope.unread.insert(ns.id.name.as_str()); + scope.namespace(ns, None, exported, false); + } + Declaration::TSImportEqualsDeclaration(alias) => { + scope.unread.insert(alias.id.name.as_str()); + } + _ => {} } } scope.mark_instantiated(); @@ -794,6 +804,13 @@ pub(crate) enum RefKind<'a> { /// An identifier with no declaration in this file that names a standard /// ECMAScript global (see [`ES_GLOBALS`]). Global, + /// Any other identifier this file doesn't declare or import: a global + /// declared outside it, in a lib such as the DOM's (`atob`, `window`) or in + /// a project `.d.ts`, which ngtsc resolves to that declaration, or a name + /// declared nowhere, which ngtsc can't resolve. oxc can't tell these apart, + /// so it's dynamic (see [`Value::is_dynamic`]), except that an input + /// transform assumes it's a function, as it does an imported one. + Ambient, /// A static getter or setter, or a static property without an /// initializer, declared at the span. StaticMember(Span), @@ -873,6 +890,12 @@ impl<'a> Value<'a> { matches!(self, Value::Reference { kind: RefKind::Import { .. }, .. }) } + /// Whether ngtsc's value is unknown: dynamic, or a global declared outside + /// the file, which is only kept as a name so a transform can refer to it. + pub(crate) fn is_dynamic(&self) -> bool { + matches!(self, Value::Dynamic | Value::Reference { kind: RefKind::Ambient, .. }) + } + /// ngtsc's `describeResolvedType`, one level deep like its diagnostics. pub(crate) fn describe(&self) -> String { self.describe_to(1) @@ -910,6 +933,7 @@ impl<'a> Value<'a> { .join(", ") ), Value::Module => "(module)".into(), + _ if self.is_dynamic() => "(not statically analyzable)".into(), Value::Reference { name, .. } | Value::Enum { name, .. } => name.clone(), Value::Builtin(_) => "Function".into(), Value::Dynamic | Value::Function(_) => "(not statically analyzable)".into(), @@ -919,9 +943,8 @@ impl<'a> Value<'a> { /// The chained line ngtsc's `createValueHasWrongTypeError` adds after a message. pub(crate) fn wrong_type_suffix(&self) -> String { match self { - Value::Dynamic | Value::Function(_) => { - " Value could not be determined statically.".into() - } + Value::Function(_) => " Value could not be determined statically.".into(), + _ if self.is_dynamic() => " Value could not be determined statically.".into(), Value::Reference { name, .. } => format!(" Value is a reference to '{name}'."), _ => format!(" Value is of type '{}'.", self.describe()), } @@ -964,10 +987,11 @@ fn quote_key(key: &str) -> String { } } -/// The `declare var` / `declare function` globals of TypeScript's ES2022 -/// library (lib.es5.d.ts ... lib.es2022.*.d.ts). ngtsc resolves these to a -/// reference to their declaration; any other undeclared name is unknown to it. -/// DOM globals depend on the `lib` option and are treated as unknown. +/// The `declare var` / `declare function` / `declare namespace` globals of +/// TypeScript's ES2022 library (lib.es5.d.ts ... lib.es2022.*.d.ts), which +/// ngtsc resolves to a reference to their declaration. Other globals (the +/// DOM's, a project's `.d.ts`) depend on the program, and are +/// [`RefKind::Ambient`]. const ES_GLOBALS: &[&str] = &[ "AggregateError", "Array", @@ -995,6 +1019,7 @@ const ES_GLOBALS: &[&str] = &[ "Int16Array", "Int32Array", "Int8Array", + "Intl", "isFinite", "isNaN", "JSON", @@ -1009,6 +1034,7 @@ const ES_GLOBALS: &[&str] = &[ "Proxy", "RangeError", "ReferenceError", + "Reflect", "RegExp", "Set", "SharedArrayBuffer", @@ -1342,7 +1368,12 @@ impl<'s, 'a> Evaluator<'s, 'a> { Some(binding) => binding.value.clone(), None => match self.slot(frame.block, name) { Some(slot) => self.stored(slot, depth), - None => self.eval(&p.value, depth, frame), + // ngtsc never resolves a shorthand to a global + // it can use as a transform. + None => match self.eval(&p.value, depth, frame) { + value if value.is_dynamic() => Value::Dynamic, + value => value, + }, }, } } @@ -1398,7 +1429,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { frame: &Frame<'a>, ) -> Value<'a> { let object = self.eval(&m.object, depth, frame); - if matches!(object, Value::Dynamic) { + if object.is_dynamic() { return Value::Dynamic; } match self.eval(&m.expression, depth, frame) { @@ -1417,7 +1448,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { frame: &Frame<'a>, ) -> Value<'a> { match self.eval(&c.test, depth, frame) { - Value::Dynamic => Value::Dynamic, + test if test.is_dynamic() => Value::Dynamic, test if test.is_import() => test, test if test.truthy() => self.eval(&c.consequent, depth, frame), _ => self.eval(&c.alternate, depth, frame), @@ -1436,7 +1467,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { return Value::Dynamic; } match self.eval(&u.argument, depth, frame) { - Value::Dynamic => Value::Dynamic, + value if value.is_dynamic() => Value::Dynamic, value if value.is_import() => value, value => unary(u.operator, &value), } @@ -1475,7 +1506,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { let left = self.eval(&l.left, depth, frame); let right = self.eval(&l.right, depth, frame); match (left, right) { - (Value::Dynamic, _) | (_, Value::Dynamic) => Value::Dynamic, + (left, right) if left.is_dynamic() || right.is_dynamic() => Value::Dynamic, (left, _) if left.is_import() => left, (left, right) => match (l.operator, left.truthy()) { (LogicalOperator::And, true) | (LogicalOperator::Or, false) => right, @@ -1556,8 +1587,11 @@ impl<'s, 'a> Evaluator<'s, 'a> { _ if ES_GLOBALS.contains(&name) => { Value::Reference { name: name.into(), kind: RefKind::Global } } - // ngtsc can't find a declaration for it. - _ => Value::Dynamic, + // TypeScript declares `globalThis` without a declaration ngtsc + // can find. + "globalThis" => Value::Dynamic, + _ if scope.unread.contains(name) => Value::Dynamic, + _ => Value::Reference { name: name.into(), kind: RefKind::Ambient }, } } @@ -2222,6 +2256,9 @@ pub(crate) fn transform_error<'a>( { return clash(); } + // A global declared outside the file (`atob`) is assumed to be a + // function, like an imported one. + Value::Reference { kind: RefKind::Ambient, .. } => return clash(), Value::Reference { .. } | Value::Dynamic => { return Some((format!("Input transform must be a function{suffix}"), node())); } diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 430db51a5..4d094a330 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 537, "fixtures compared"); + assert_eq!(compared, 557, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -795,10 +795,10 @@ export class Dir { @Input({transform: core.booleanAttribute}) v: any; } } /// A transform declared in another file (an import, a namespace member or a -/// global from TypeScript's lib): ngtsc 22.1.7 reports these at that -/// declaration, which the snapshot can't record (the fixtures below are -/// skipped for that reason, with the diagnostics ngtsc reported). oxc reports -/// the same message on the expression. +/// global from TypeScript's lib, like `Intl` or the DOM's `atob`): ngtsc +/// 22.1.7 reports these at that declaration, which the snapshot can't record +/// (the fixtures below are skipped for that reason, with the diagnostics ngtsc +/// reported). oxc reports the same message on the expression. #[test] fn transform_declared_in_another_file_is_reported_on_the_expression() { let fixtures: Value = serde_json::from_str(FIXTURES).unwrap(); @@ -810,6 +810,9 @@ fn transform_declared_in_another_file_is_reported_on_the_expression() { ("probe: transform-globalNumberClash", "Number"), ("probe: transform-globalString", "String"), ("probe: transform-nsClash", "u.toNum"), + ("probe: transform-global-Intl", "Intl"), + ("probe: transform-global-Reflect", "Reflect"), + ("probe: transform-global-clashAtob", "atob"), ]; for (name, expression) in cases { let fixture = fixtures["fixtures"] @@ -829,6 +832,58 @@ fn transform_declared_in_another_file_is_reported_on_the_expression() { } } +/// A name the file declares as a namespace or an `import x = ...` alias isn't +/// a global declared elsewhere, so it isn't assumed to be a function the way +/// `atob` is. ngtsc 22.1.7 rejects `transform: U` too, as "Value is a reference +/// to 'U'" (oxc doesn't evaluate namespaces), and accepts `transform: f` +/// (oxc doesn't follow the alias). +#[test] +fn file_namespaces_and_aliases_are_not_assumed_to_be_global_functions() { + let namespace = "namespace U { export function f(v: string) { return 1; } }\n"; + for (pre, expr) in + [(namespace.to_string(), "U"), (format!("{namespace}import f = U.f;\n"), "f")] + { + let source = format!( + "import {{Directive, Input}} from '@angular/core';\n{pre}\ + @Directive({{selector: '[d]'}})\n\ + export class Dir {{\n @Input({{transform: {expr}}}) x: any;\n}}\n" + ); + assert_eq!( + errors(&transform(&source), &source), + vec![( + "Input transform must be a function Value could not be determined statically." + .to_string(), + expr.to_string() + )], + "{expr}" + ); + } +} + +/// A shorthand `{ transform }` naming a global is looked up through +/// TypeScript's shorthand symbol, which ngtsc 22.1.7 never accepts as a +/// transform: "could not be determined statically" when nothing declares +/// it, and an error about the declaration when a lib or `.d.ts` does (at +/// that declaration). So unlike `transform: atob`, it isn't assumed to be a +/// function. +#[test] +fn shorthand_transform_naming_a_global_is_rejected() { + let source = "import {Directive} from '@angular/core'; +@Directive({selector: '[d]', inputs: [{name: 'x', transform}]}) +export class Dir { + x!: any; +} +"; + assert_eq!( + errors(&transform(source), source), + vec![( + "Input transform must be a function Value could not be determined statically." + .to_string(), + "transform".to_string() + )] + ); +} + /// ngtsc checks an overloaded static method's first declaration, not its /// implementation (checked with @angular/compiler-cli 22.1.7, which compiles /// this; the snapshot can't hold it because ngtsc emits the method's bare name, diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 5870bba78..0f55eb17f 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -6464,6 +6464,553 @@ "dtsImports": [], "diagnosticSpans": [[123, 124]] }, + { + "name": "probe: transform-global-atob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: atob}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-btoa", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: btoa}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",btoa]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-alert", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: alert}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",alert]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-globalThis", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: globalThis}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 128]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-undefined", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: undefined}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'undefined'."], + "diagnosticSpans": [[106, 128]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-inputsAtob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: atob}]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",atob]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-projectFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: ambientFn}) x: any;\n}\n", + "globals.d.ts": "declare function ambientFn(v: string): number;\ndeclare var ambientVar: string;\ndeclare function ambientOverload(v: string): number;\ndeclare function ambientOverload(v: number): number;\ndeclare function ambientGeneric(v: T): T;\ndeclare namespace AmbNs { function f(v: string): number; }\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",ambientFn]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-projectNamespaceMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: AmbNs.f}) x: any;\n}\n", + "globals.d.ts": "declare function ambientFn(v: string): number;\ndeclare var ambientVar: string;\ndeclare function ambientOverload(v: string): number;\ndeclare function ambientOverload(v: number): number;\ndeclare function ambientGeneric(v: T): T;\ndeclare namespace AmbNs { function f(v: string): number; }\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 125]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-declareGlobalFunction", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\ndeclare global { function gg(v: string): number; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: gg}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",gg]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-aliasWindow", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: window as any}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-aliasUndeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({alias: undeclaredName}) x: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{x:\"x\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-global-Intl", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: Intl}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'Intl'."], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es5.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-global-Reflect", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: Reflect}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'Reflect'."], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.es2015.reflect.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-global-clashAtob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n static ngAcceptInputType_x: string;\n @Input({transform: atob}) x: any;\n}\n" + }, + "diagnostics": [ + "Class cannot have both a transform function on Input x and a static member called ngAcceptInputType_x" + ], + "classes": {}, + "dtsImports": [], + "skip": "ngtsc reports this at the transform's declaration in TypeScript's lib.dom.d.ts, a file oxc doesn't read (so this snapshot's generator, which keeps test.ts diagnostics, records none); oxc reports the same message on the expression" + }, + { + "name": "probe: transform-global-window", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: window}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'window'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-document", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: document}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'document'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-navigator", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: navigator}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'navigator'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-localStorage", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: localStorage}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'localStorage'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-console", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: console}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'console'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-self", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: self}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'self'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-CSS", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: CSS}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'CSS'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-WebAssembly", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: WebAssembly}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'WebAssembly'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-structuredClone", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: structuredClone}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform function cannot be generic Value is a reference to 'structuredClone'." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-queueMicrotask", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: queueMicrotask}) x: any;\n}\n" + }, + "diagnostics": [ + "Unable to import type VoidFunction. The symbol is not exported from /Users/brooklyn/workspace/github/oxc-angular-compiler/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/lib/lib.dom.d.ts." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-setTimeout", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: setTimeout}) x: any;\n}\n" + }, + "diagnostics": [ + "Unable to import type TimerHandler. The symbol is not exported from /Users/brooklyn/workspace/github/oxc-angular-compiler/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/lib/lib.dom.d.ts." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-fetch", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: fetch}) x: any;\n}\n" + }, + "diagnostics": [ + "Unable to import type RequestInfo. The symbol is not exported from /Users/brooklyn/workspace/github/oxc-angular-compiler/node_modules/.pnpm/typescript@6.0.3/node_modules/typescript/lib/lib.dom.d.ts." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-projectVar", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: ambientVar}) x: any;\n}\n", + "globals.d.ts": "declare function ambientFn(v: string): number;\ndeclare var ambientVar: string;\ndeclare function ambientOverload(v: string): number;\ndeclare function ambientOverload(v: number): number;\ndeclare function ambientGeneric(v: T): T;\ndeclare namespace AmbNs { function f(v: string): number; }\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'ambientVar'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (a project `.d.ts`), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-projectOverload", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: ambientOverload}) x: any;\n}\n", + "globals.d.ts": "declare function ambientFn(v: string): number;\ndeclare var ambientVar: string;\ndeclare function ambientOverload(v: string): number;\ndeclare function ambientOverload(v: number): number;\ndeclare function ambientGeneric(v: T): T;\ndeclare namespace AmbNs { function f(v: string): number; }\n" + }, + "diagnostics": [ + "Input transform function cannot have multiple signatures Value is a reference to 'ambientOverload'." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (a project `.d.ts`), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-projectGeneric", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: ambientGeneric}) x: any;\n}\n", + "globals.d.ts": "declare function ambientFn(v: string): number;\ndeclare var ambientVar: string;\ndeclare function ambientOverload(v: string): number;\ndeclare function ambientOverload(v: number): number;\ndeclare function ambientGeneric(v: T): T;\ndeclare namespace AmbNs { function f(v: string): number; }\n" + }, + "diagnostics": [ + "Input transform function cannot be generic Value is a reference to 'ambientGeneric'." + ], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (a project `.d.ts`), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-inputsWindow", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: window}]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'window'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (TypeScript's DOM lib), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-declareGlobalVar", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\ndeclare global { var gv: string; }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: gv}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is of type 'undefined'."], + "classes": {}, + "dtsImports": [], + "skip": "a global declared outside this file (this file's `declare global`, whose names oxc doesn't track), which oxc doesn't read: oxc assumes it's a function and accepts it, as it does an imported transform (only the `ngAcceptInputType_` clash is checked), where ngtsc reads the declaration and reports this" + }, + { + "name": "probe: transform-global-namespaceMember", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\nnamespace U { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: U.f}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[177, 180]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-declareModule", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\ndeclare module M { }\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: M}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[139, 140]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-undeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: undeclaredName}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "skip": "a name declared nowhere, which TypeScript reports: oxc can't tell it from a global declared outside the file, so it assumes it's a function and accepts it, where ngtsc reports this" + }, + { + "name": "probe: transform-global-inputsUndeclared", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: undeclaredName}]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [], + "skip": "a name declared nowhere, which TypeScript reports: oxc can't tell it from a global declared outside the file, so it assumes it's a function and accepts it, where ngtsc reports this" + }, + { + "name": "probe: transform-global-member-window_atob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: window.atob}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-document_createElement", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: document.createElement}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 140]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-globalThis_atob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: globalThis.atob}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 133]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-self_atob", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: self.atob}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 127]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-console_log", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: console.log}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-undeclaredName_foo", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: undeclaredName.foo}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 136]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: transform-global-member-localStorage_getItem", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from '@angular/core';\n@Directive({selector: '[d]'})\nexport class Dir {\n @Input({transform: localStorage.getItem}) x: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[118, 138]], + "classes": {}, + "dtsImports": [] + }, { "name": "probe: shortCircuit-metaOr", "origin": "probe", From 8578ffe4a35bef676fe47b5ed0ec148a85856e00 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 1 Oct 2026 04:31:13 +0800 Subject: [PATCH 7/7] fix(directive): validate transforms from same-file namespaces like ngtsc With namespaces evaluated, a transform can now be a function, static method or namespace declared in a namespace, or reached through an import-equals alias. The checks follow ngtsc for them: - A namespace (`transform: U`) is "a reference to 'U'", reported at its first declaration that declares a value. - A function or static method declared in a namespace is checked from its first declaration there, and errors point at it. - Its parameter's types resolve in the namespace: a type that isn't marked `export` there (also in `declare namespace`, where it's still visible from outside) is "Symbol must be exported ...", like a top-level one. - `import f = U.f` is the function it names, emitted as `f`. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/directive/evaluator.rs | 195 ++++++++--- .../tests/decorator_metadata_ngtsc_test.rs | 46 +-- .../fixtures/decorator_metadata_ngtsc.json | 304 ++++++++++++++++++ 3 files changed, 477 insertions(+), 68 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 28cf19c6d..a02c214c4 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -106,14 +106,17 @@ struct NamespaceBlock<'a> { /// The namespace's name, qualified from the top level (`A.B`). name: String, span: Span, + /// The statement that declares it (with `export`), where ngtsc's + /// diagnostics point. + statement: Span, } /// What `typeof A.B.X` names, for `A.B` a namespace the file declares. enum Qualified<'a> { /// The value `X` declared (and exported) in a namespace block. Declared(usize, &'a str), - /// The (instantiated) namespace `X`. - Namespace(&'a str), + /// The (instantiated) namespace `X`, and its id. + Namespace(&'a str, usize), /// Nothing TypeScript can resolve: `X` isn't exported, or doesn't exist. Missing, } @@ -216,7 +219,7 @@ impl<'a> FileScope<'a> { match decl { Declaration::TSNamespaceDeclaration(ns) => { scope.unread.insert(ns.id.name.as_str()); - scope.namespace(ns, None, exported, false); + scope.namespace(ns, None, exported, false, stmt.span()); } Declaration::TSImportEqualsDeclaration(alias) => { scope.unread.insert(alias.id.name.as_str()); @@ -229,13 +232,15 @@ impl<'a> FileScope<'a> { } /// Collects the namespace `ns`, declared in `parent` (`None`: at the top - /// level), and in an ambient context (`declare namespace`) if `ambient`. + /// level) by `statement`, and in an ambient context (`declare namespace`) + /// if `ambient`. fn namespace( &mut self, ns: &'a TSNamespaceDeclaration<'a>, parent: Option, exported: bool, ambient: bool, + statement: Span, ) { let name = ns.id.name.as_str(); // Every declaration in an ambient namespace is exported, with or @@ -271,12 +276,13 @@ impl<'a> FileScope<'a> { parent, name: qualified, span: ns.span, + statement, ..NamespaceBlock::default() }); let statements = match &ns.body { // `namespace A.B {}`: `B` is an exported member of `A`. TSNamespaceDeclarationBody::TSNamespaceDeclaration(inner) => { - self.namespace(inner, Some(index), true, ambient); + self.namespace(inner, Some(index), true, ambient, inner.span); return; } TSNamespaceDeclarationBody::TSModuleBlock(block) => &block.body, @@ -287,11 +293,17 @@ impl<'a> FileScope<'a> { let (decl, exported, span) = match stmt { Statement::ExportDeclaration(export) => (&export.declaration, true, export.span), _ => match stmt.as_declaration() { - Some(decl) => (decl, ambient, decl.span()), + Some(decl) => (decl, false, decl.span()), None => continue, }, }; scope.declaration(decl, exported, span); + // Without `export` it isn't statically exported for ngtsc (see + // `UnexportedType`), but still visible outside the namespace. + let exported = exported || ambient; + if exported { + scope.exported.extend(value_names(decl)); + } match decl { Declaration::FunctionDeclaration(f) => { self.declared_in.insert(std::ptr::from_ref::(f) as usize, index); @@ -305,13 +317,15 @@ impl<'a> FileScope<'a> { } } } - Declaration::TSNamespaceDeclaration(inner) => nested.push((&**inner, exported)), + Declaration::TSNamespaceDeclaration(inner) => { + nested.push((&**inner, exported, span)); + } _ => {} } } self.blocks[index].scope = scope; - for (inner, exported) in nested { - self.namespace(inner, Some(index), exported, ambient); + for (inner, exported, span) in nested { + self.namespace(inner, Some(index), exported, ambient, span); } } @@ -320,15 +334,9 @@ impl<'a> FileScope<'a> { fn mark_instantiated(&mut self) { loop { let before = self.instantiated.len(); - for block in &self.blocks { - let scope = &block.scope; - let values = !scope.variables.is_empty() - || !scope.functions.is_empty() - || !scope.classes.is_empty() - || !scope.enums.is_empty() - || block.namespaces.values().any(|id| self.instantiated.contains(id)); - if values && !self.instantiated.contains(&block.namespace) { - self.instantiated.insert(block.namespace); + for b in 0..self.blocks.len() { + if self.block_has_values(b) { + self.instantiated.insert(self.blocks[b].namespace); } } if self.instantiated.len() == before { @@ -337,6 +345,65 @@ impl<'a> FileScope<'a> { } } + /// Whether the namespace block `b` declares a value: a variable, + /// function, class or enum, or an instantiated namespace. + fn block_has_values(&self, b: usize) -> bool { + let block = &self.blocks[b]; + let scope = &block.scope; + !scope.variables.is_empty() + || !scope.functions.is_empty() + || !scope.classes.is_empty() + || !scope.enums.is_empty() + || block.namespaces.values().any(|id| self.instantiated.contains(id)) + } + + /// A reference to the (instantiated) namespace `name` with the id `id`, + /// which ngtsc points at the first declaration of it that declares a value. + fn namespace_reference<'v>(&self, name: &str, id: usize) -> Value<'v> { + let span = (0..self.blocks.len()) + .find(|&b| self.blocks[b].namespace == id && self.block_has_values(b)) + .map_or(Span::default(), |b| self.blocks[b].statement); + Value::Reference { name: name.into(), kind: RefKind::Namespace(span) } + } + + /// The innermost namespace block whose declaration contains `span`. + fn block_at(&self, span: Span) -> Option { + (0..self.blocks.len()) + .filter(|&b| self.blocks[b].span.contains_inclusive(span)) + .min_by_key(|&b| self.blocks[b].span.size()) + } + + /// For a type named `name` in the namespace block `block` (`None`: at the + /// top level), whether ngtsc's `isStaticallyExported` holds for the + /// declaration it resolves to (see [`Self::type_is_exported`]): in a + /// namespace, whether it's marked `export`. `None` for a name the file + /// doesn't declare as a type. + fn type_is_exported_in(&self, block: Option, name: &str) -> Option { + let mut current = block; + while let Some(b) = current { + let namespace = self.blocks[b].namespace; + if let Some(exported) = self.blocks[b].scope.types.get(name) { + return Some(*exported); + } + // Exported from another body of the namespace. + if self + .blocks + .iter() + .any(|o| o.namespace == namespace && o.scope.types.get(name) == Some(&true)) + { + return Some(true); + } + current = self.blocks[b].parent; + } + self.type_is_exported(name) + } + + /// The declarations of the scope (a namespace block or the top level) the + /// function, static method or class `node` is declared in. + fn scope_of(&self, node: &T) -> &FileScope<'a> { + self.declarations(self.block_of(node)) + } + /// Whether the namespace block `b` itself declares the value `name`: a /// variable, function, class, enum, import-equals alias or instantiated /// namespace. @@ -460,10 +527,12 @@ impl<'a> FileScope<'a> { None => return Some(Qualified::Missing), } } - Some(match self.exporting_block(namespace, last) { - Some(b) if self.blocks[b].namespaces.contains_key(last) => Qualified::Namespace(last), - Some(b) => Qualified::Declared(b, last), - None => Qualified::Missing, + let Some(b) = self.exporting_block(namespace, last) else { + return Some(Qualified::Missing); + }; + Some(match self.blocks[b].namespaces.get(last) { + Some(id) => Qualified::Namespace(last, *id), + None => Qualified::Declared(b, last), }) } @@ -471,10 +540,7 @@ impl<'a> FileScope<'a> { /// that namespace's declarations (or an enclosing one's), which don't /// resolve at the top level. pub(crate) fn namespace_used_by(&self, expr: &Expression<'a>) -> Option<&str> { - let span = expr.span(); - let block = (0..self.blocks.len()) - .filter(|&b| self.blocks[b].span.contains_inclusive(span)) - .min_by_key(|&b| self.blocks[b].span.size())?; + let block = self.block_at(expr.span())?; let frame = Frame::at(Some(block)); let mut uses = UsesFrame::new(&frame, self); uses.visit_expression(expr); @@ -541,10 +607,12 @@ impl<'a> FileScope<'a> { /// implementation is `function`: its first overload, if any. That's /// TypeScript's value declaration, the one ngtsc checks as a transform. fn first_declaration(&self, name: &str, function: &'a Function<'a>) -> &'a Function<'a> { - if self.is_top_level_function(name, function) { - return self.first_functions.get(name).copied().unwrap_or(function); + // In the namespace it's declared in, if any. + let scope = self.scope_of(function); + if scope.is_top_level_function(name, function) { + return scope.first_functions.get(name).copied().unwrap_or(function); } - self.first_static_method(function).map_or(function, |m| &m.value) + scope.first_static_method(function).map_or(function, |m| &m.value) } /// The first declaration of the static method whose implementation is @@ -655,14 +723,16 @@ impl<'a> FileScope<'a> { /// The type parameters of the innermost same-file function or static /// method (the functions the evaluator calls) whose body contains `span`. fn enclosing_type_parameters(&self, span: Span) -> Option<&'a TSTypeParameterDeclaration<'a>> { - let methods = self.classes.values().flat_map(|class| { + // Declared at the top level or in a namespace. + let scopes = || std::iter::once(self).chain(self.blocks.iter().map(|b| &b.scope)); + let methods = scopes().flat_map(|scope| scope.classes.values()).flat_map(|class| { class.body.body.iter().filter_map(|el| match el { ClassElement::MethodDefinition(m) if m.r#static => Some(&*m.value), _ => None, }) }); - self.functions - .values() + scopes() + .flat_map(|scope| scope.functions.values()) .map(|(function, _)| *function) .chain(methods) .filter(|f| f.body.as_ref().is_some_and(|body| body.span.contains_inclusive(span))) @@ -671,6 +741,28 @@ impl<'a> FileScope<'a> { } } +/// The values a declaration declares (not its types, or a namespace). +fn value_names<'a>(decl: &'a Declaration<'a>) -> std::vec::Vec<&'a str> { + let mut names = std::vec::Vec::new(); + match decl { + Declaration::VariableDeclaration(vars) => { + for var in &vars.declarations { + let mut bindings = std::vec::Vec::new(); + collect_bindings(&var.id, &mut std::vec::Vec::new(), &mut bindings); + names.extend(bindings.into_iter().map(|(name, _)| name)); + } + } + Declaration::FunctionDeclaration(f) => { + names.extend(f.id.as_ref().map(|id| id.name.as_str())) + } + Declaration::ClassDeclaration(c) => names.extend(c.id.as_ref().map(|id| id.name.as_str())), + Declaration::TSEnumDeclaration(e) => names.push(e.id.name.as_str()), + Declaration::TSImportEqualsDeclaration(a) => names.push(a.id.name.as_str()), + _ => {} + } + names +} + /// The names a binding pattern declares, each with its path into the initializer. fn collect_bindings<'a>( pattern: &'a BindingPattern<'a>, @@ -814,6 +906,9 @@ pub(crate) enum RefKind<'a> { /// A static getter or setter, or a static property without an /// initializer, declared at the span. StaticMember(Span), + /// A namespace that declares a value, whose first declaration that does + /// is at the span. + Namespace(Span), /// Any other declaration (a `declare`d variable, an enum member, ...). Other, } @@ -1629,7 +1724,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { }; namespace .filter(|id| file.instantiated.contains(id)) - .map(|_| Value::Reference { name: name.into(), kind: RefKind::Other }) + .map(|id| file.namespace_reference(name, *id)) } /// The value of an import-equals alias declared in `block`: what its @@ -1651,8 +1746,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { TSModuleReference::IdentifierReference(id) => { return match scope.namespace_of(block, id.name.as_str(), 0) { Some(ns) if scope.instantiated.contains(&ns) => { - let name = scope.namespace_names[ns]; - Value::Reference { name: name.into(), kind: RefKind::Other } + scope.namespace_reference(scope.namespace_names[ns], ns) } _ => Value::Dynamic, }; @@ -1664,9 +1758,7 @@ impl<'s, 'a> Evaluator<'s, 'a> { Some(Qualified::Declared(b, member)) => { self.declared(Some(b), member, depth).unwrap_or(Value::Dynamic) } - Some(Qualified::Namespace(name)) => { - Value::Reference { name: name.into(), kind: RefKind::Other } - } + Some(Qualified::Namespace(name, id)) => scope.namespace_reference(name, id), _ => Value::Dynamic, } } @@ -1882,8 +1974,8 @@ impl<'s, 'a> Evaluator<'s, 'a> { Some(Qualified::Declared(b, member)) => { return self.declared(Some(b), member, depth).unwrap_or(Value::Dynamic); } - Some(Qualified::Namespace(name)) => { - return Value::Reference { name: name.into(), kind: RefKind::Other }; + Some(Qualified::Namespace(name, id)) => { + return scope.namespace_reference(name, id); } Some(Qualified::Missing) => return Value::Dynamic, None => {} @@ -2310,8 +2402,10 @@ pub(crate) fn transform_error<'a>( Value::Function(_) => scope.enclosing_type_parameters(def.span()), _ => None, }; + // Its types resolve where it's written, in a namespace or not. + let block = scope.block_at(def.span()); let mut check = - UnexportedType { scope, type_params: std::vec::Vec::new(), found: false }; + UnexportedType { scope, block, type_params: std::vec::Vec::new(), found: false }; check.with_params(helper_params, |check| { check.with_params(class_params, |check| check.visit_ts_type(ty)); }); @@ -2332,17 +2426,22 @@ fn value_node_span(value: &Value<'_>, expr_span: Span, scope: &FileScope<'_>) -> let declared = |name: &str| scope.declaration_spans.get(name).copied(); let span = match value { Value::Function(def) => Some(def.span()), + // Declared at the top level or in a namespace. Value::Reference { name, kind: RefKind::Function(function, _) } => { + let scope = scope.scope_of(*function); if scope.is_top_level_function(name, function) { - declared(name) + scope.declaration_spans.get(name.as_str()).copied() } else { scope.first_static_method(function).map(|m| m.span) } } - Value::Reference { kind: RefKind::Class(class), .. } => { - class.id.as_ref().and_then(|id| declared(id.name.as_str())) - } - Value::Reference { kind: RefKind::StaticMember(span), .. } => Some(*span), + Value::Reference { kind: RefKind::Class(class), .. } => class + .id + .as_ref() + .and_then(|id| scope.scope_of(*class).declaration_spans.get(id.name.as_str()).copied()), + Value::Reference { + kind: RefKind::StaticMember(span) | RefKind::Namespace(span), .. + } => Some(*span), Value::Reference { name, kind: RefKind::Other } if matches!(scope.variables.get(name.as_str()), Some(Variable::Declared(_))) => { @@ -2360,6 +2459,8 @@ fn value_node_span(value: &Value<'_>, expr_span: Span, scope: &FileScope<'_>) -> /// left side of `X.Y` aren't type references, so they're never checked. struct UnexportedType<'s, 'a> { scope: &'s FileScope<'a>, + /// The namespace block the function is written in (`None`: the top level). + block: Option, /// The type parameters in scope, innermost last. type_params: std::vec::Vec<&'a str>, found: bool, @@ -2388,7 +2489,7 @@ impl<'a> Visit<'a> for UnexportedType<'_, 'a> { let exported = if self.type_params.contains(&name) { Some(false) } else { - self.scope.type_is_exported(name) + self.scope.type_is_exported_in(self.block, name) }; self.found |= exported == Some(false); } diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs index 4d094a330..a0d5987eb 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -299,7 +299,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 557, "fixtures compared"); + assert_eq!(compared, 575, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -834,30 +834,34 @@ fn transform_declared_in_another_file_is_reported_on_the_expression() { /// A name the file declares as a namespace or an `import x = ...` alias isn't /// a global declared elsewhere, so it isn't assumed to be a function the way -/// `atob` is. ngtsc 22.1.7 rejects `transform: U` too, as "Value is a reference -/// to 'U'" (oxc doesn't evaluate namespaces), and accepts `transform: f` -/// (oxc doesn't follow the alias). +/// `atob` is: it's resolved like ngtsc 22.1.7 does. `transform: U` is a +/// reference to the namespace (at its declaration), and `transform: f` is the +/// function the alias names, emitted by the alias's name. The snapshot's +/// `r6-nsval-*` and `r6-alias-*` probes cover more forms. #[test] fn file_namespaces_and_aliases_are_not_assumed_to_be_global_functions() { - let namespace = "namespace U { export function f(v: string) { return 1; } }\n"; - for (pre, expr) in - [(namespace.to_string(), "U"), (format!("{namespace}import f = U.f;\n"), "f")] - { - let source = format!( - "import {{Directive, Input}} from '@angular/core';\n{pre}\ + let namespace = "namespace U { export function f(v: string) { return 1; } }"; + let source = |pre: &str, expr: &str| { + format!( + "import {{Directive, Input}} from '@angular/core';\n{pre}\n\ @Directive({{selector: '[d]'}})\n\ export class Dir {{\n @Input({{transform: {expr}}}) x: any;\n}}\n" - ); - assert_eq!( - errors(&transform(&source), &source), - vec![( - "Input transform must be a function Value could not be determined statically." - .to_string(), - expr.to_string() - )], - "{expr}" - ); - } + ) + }; + + let with_namespace = source(namespace, "U"); + assert_eq!( + errors(&transform(&with_namespace), &with_namespace), + vec![( + "Input transform must be a function Value is a reference to 'U'.".to_string(), + namespace.to_string() + )] + ); + + let with_alias = source(&format!("{namespace}\nimport f = U.f;"), "f"); + let result = transform(&with_alias); + assert_eq!(errors(&result, &with_alias), vec![]); + assert!(strip(&result.code).contains(r#"inputs:{x:[2,"x","x",f]}"#), "{}", result.code); } /// A shorthand `{ transform }` naming a global is looked up through diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json index 0f55eb17f..94c50db12 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -8688,6 +8688,310 @@ } }, "dtsImports": [] + }, + { + "name": "probe: r6-ns-fn-transform-plain", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export function fn(v: string) { return 1; } }\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: NS.fn}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[195, 200]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-ns-fn-transform-member", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export function fn(v: string) { return 1; } }\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input({transform: NS.fn}) a: any;\n}\n" + }, + "diagnostics": [ + "Input transform must be a function Value could not be determined statically." + ], + "diagnosticSpans": [[206, 211]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-ns-generic-fn-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export function fn(v: T) { return 1; } }\ndeclare const T: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: T}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": ["Input transform function cannot be generic Value is a reference to 'fn'."], + "diagnosticSpans": [[88, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-ns-untyped-fn-typeof", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export function fn(v) { return 1; } }\ndeclare const T: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: T}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Input transform function first parameter must have a type Value is a reference to 'fn'." + ], + "diagnosticSpans": [[88, 123]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-nsval-member-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input({transform: U}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[73, 131]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-nsval-meta-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: U}]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[73, 131]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-nsval-typeof-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export function f(v: string) { return 1; } }\ndeclare const X: typeof U;\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: X}]})\nexport class Dir {\n x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[73, 131]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-nsval-exported-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nexport namespace U { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input({transform: U}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[73, 138]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-nsval-merged-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export type T = string; }\nnamespace U { export const a = 1; }\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input({transform: U}) x: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[113, 148]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-overload", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export function fn(v: string): number; export function fn(v: any) { return 1; } }\ndeclare const T: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: T}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[2,\"a\",\"a\",fn]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_a": "staticngAcceptInputType_a:string;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits `fn` for `transform: T` with `declare const T: typeof NS.fn` (a function declared in a namespace), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `T`" + }, + { + "name": "probe: r6-t494-static", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export class C { static t(v: string) { return 1; } } }\ndeclare const X: typeof NS.C;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: X.t}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[2,\"a\",\"a\",t]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_a": "staticngAcceptInputType_a:string;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits `t` for `transform: X.t` (a static method of a class declared in a namespace), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `X.t`" + }, + { + "name": "probe: r6-t494-static-generic", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export class C { static t(v: T) { return 1; } } }\ndeclare const X: typeof NS.C;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: X.t}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": ["Input transform function cannot be generic Value is a reference to 't'."], + "diagnosticSpans": [[105, 136]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-local-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { type T = string; export function fn(v: T) { return 1; } }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[127, 128]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-exported-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export type T = string; export function fn(v: T) { return 1; } }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[2,\"a\",\"a\",fn]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_a": "staticngAcceptInputType_a:T;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits `fn` for `transform: T` with `declare const T: typeof NS.fn` (a function declared in a namespace), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `T`" + }, + { + "name": "probe: r6-t494-namespace-member-clash", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export function f(v: string) { return 1; } }\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: U}]})\nexport class Dir {\n a: any;\n static ngAcceptInputType_a: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'U'."], + "diagnosticSpans": [[73, 131]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-alias-generic", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export function f(v: T) { return 1; } }\nimport f = U.f;\n@Directive({selector: '[d]', })\nexport class Dir {\n @Input({transform: f}) x: any;\n}\n" + }, + "diagnostics": ["Input transform function cannot be generic Value is a reference to 'f'."], + "diagnosticSpans": [[87, 127]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-nested-namespace-transform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace U { export namespace V { export const a = 1; } }\ndeclare const X: typeof U.V;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: X}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": ["Input transform must be a function Value is a reference to 'V'."], + "diagnosticSpans": [[87, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-ambient-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\ndeclare namespace NS { type T = string; function fn(v: T): number; }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[128, 129]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-ambient-export-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\ndeclare namespace NS { export type T = string; function fn(v: T): number; }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[2,\"a\",\"a\",fn]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_a": "staticngAcceptInputType_a:T;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits `fn` for `transform: T` with `declare const T: typeof NS.fn` (a function declared in a namespace), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `T`" + }, + { + "name": "probe: r6-t494-merged-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { export type T = string; }\nnamespace NS { export function fn(v: T) { return 1; } }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [], + "diagnosticSpans": [], + "classes": { + "Dir": { + "inputs": "{a:[2,\"a\",\"a\",fn]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_a": "staticngAcceptInputType_a:T;" + } + }, + "dtsImports": [], + "skip": "ngtsc emits `fn` for `transform: T` with `declare const T: typeof NS.fn` (a function declared in a namespace), an identifier that isn't in scope, so its output throws a ReferenceError when the module loads; oxc keeps `T`" + }, + { + "name": "probe: r6-t494-outer-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\ntype T = string;\nnamespace NS { export function fn(v: T) { return 1; } }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[127, 128]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-local-class-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { class K {} export function fn(v: K) { return 1; } }\ndeclare const F: typeof NS.fn;\n@Directive({selector: '[d]', inputs: [{name: 'a', transform: F}]})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[121, 122]], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: r6-t494-arrow-local-type", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, ContentChild, ViewChild} from '@angular/core';\nnamespace NS { interface I { a: 1 } export const INPUTS = [{name: 'a', transform: (v: I) => 1}]; }\ndeclare const X: typeof NS.INPUTS;\n@Directive({selector: '[d]', inputs: X})\nexport class Dir {\n a: any;\n b: any;\n}\n" + }, + "diagnostics": [ + "Symbol must be exported in order to be used as the type of an Input transform function" + ], + "diagnosticSpans": [[159, 160]], + "classes": {}, + "dtsImports": [] } ] }