diff --git a/Cargo.lock b/Cargo.lock index 61dcc1313..a4a7d25eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1030,6 +1030,7 @@ dependencies = [ "pathdiff", "rustc-hash", "semver", + "serde_json", "tempfile", ] diff --git a/crates/oxc_angular_compiler/Cargo.toml b/crates/oxc_angular_compiler/Cargo.toml index 9ac749661..9ac25ef88 100644 --- a/crates/oxc_angular_compiler/Cargo.toml +++ b/crates/oxc_angular_compiler/Cargo.toml @@ -42,6 +42,7 @@ cross_file_elision = ["oxc_resolver", "pathdiff"] [dev-dependencies] insta = { workspace = true, features = ["glob"] } +serde_json = { workspace = true } tempfile = "3" [lints] diff --git a/crates/oxc_angular_compiler/src/component/decorator.rs b/crates/oxc_angular_compiler/src/component/decorator.rs index 3b1f93a9a..604dad1c9 100644 --- a/crates/oxc_angular_compiler/src/component/decorator.rs +++ b/crates/oxc_angular_compiler/src/component/decorator.rs @@ -19,7 +19,7 @@ use super::metadata::{ use super::transform::ImportMap; use crate::directive::{ StringConsts, extract_host_bindings, extract_host_listeners, extract_input_metadata, - extract_output_metadata, + extract_output_metadata, merge_by_class_property, parse_decorator_io, }; use crate::output::oxc_converter::convert_oxc_expression; @@ -261,11 +261,18 @@ pub fn extract_component_metadata<'a>( metadata.constructor_deps = extract_constructor_deps(allocator, class, import_map, has_superclass); - // Extract inputs from @Input decorators on class members - metadata.inputs = extract_input_metadata(allocator, class, source_text); - - // Extract outputs from @Output decorators on class members - metadata.outputs = extract_output_metadata(allocator, class); + // 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); + metadata.inputs = merge_by_class_property( + io.inputs, + extract_input_metadata(allocator, class, source_text), + |i| i.class_property_name.as_str(), + ); + metadata.outputs = + merge_by_class_property(io.outputs, extract_output_metadata(allocator, class), |o| { + o.0.as_str() + }); // Detect if ngOnChanges lifecycle hook is implemented // Similar to Angular's: const usesOnChanges = members.some(member => ...) diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index 0eed37813..0a699d38e 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -21,6 +21,7 @@ mod transform; #[cfg(feature = "cross_file_elision")] pub use cross_file_elision::CrossFileAnalyzer; pub use decorator::extract_component_metadata; +pub(crate) use decorator::find_component_decorator; pub use definition::{ ComponentDefinitions, const_value_to_expression, generate_component_definitions, }; diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 981a40095..abb63afc1 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -41,7 +41,7 @@ use crate::class_metadata::{ use crate::directive::collect_string_consts; use crate::directive::{ R3QueryMetadata, create_content_queries_function, create_view_queries_function, - extract_content_queries, extract_directive_metadata, extract_view_queries, + decorator_io_errors, extract_content_queries, extract_directive_metadata, extract_view_queries, find_directive_decorator, find_directive_decorator_span, generate_directive_definitions, }; use crate::dts; @@ -2578,6 +2578,11 @@ pub fn transform_angular_file( // Compute implicit_standalone based on Angular version let implicit_standalone = options.implicit_standalone(); + // `inputs:`/`outputs:` forms ngtsc rejects, rather than dropping them silently. + for error in decorator_io_errors(allocator, class, &string_consts) { + result.diagnostics.push(OxcDiagnostic::error(error)); + } + if let Some(mut metadata) = extract_component_metadata( &allocator, class, diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index b45fda650..8a25679c8 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -8,14 +8,16 @@ use std::collections::HashMap; use oxc_allocator::{Allocator, Box, Vec}; use oxc_ast::ast::{ Argument, ArrayExpressionElement, BindingPattern, Class, ClassElement, Declaration, Decorator, - Expression, MethodDefinitionKind, ObjectPropertyKind, Program, PropertyKey, Statement, - TemplateLiteral, VariableDeclarationKind, + Expression, MethodDefinitionKind, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, + Statement, TemplateLiteral, VariableDeclarationKind, }; use oxc_span::Span; use oxc_str::Ident; +use super::evaluator::{Evaluator, FileScope, Value}; use super::metadata::{ R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, + R3InputMetadata, }; use crate::factory::R3DependencyMetadata; use crate::output::ast::{OutputAstBuilder, OutputExpression, ReadVarExpr}; @@ -117,6 +119,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)); // Parse each property in the config object (if present) if let Some(config_obj) = config_obj { @@ -203,6 +206,14 @@ pub fn extract_directive_metadata<'a>( // The builder already has host data from extract_from_class, we need to merge the decorator host let mut metadata = builder.build()?; + if let Some(io) = io { + let fields = std::mem::replace(&mut metadata.inputs, Vec::new_in(&allocator)); + metadata.inputs = + merge_by_class_property(io.inputs, fields, |i| i.class_property_name.as_str()); + 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()); + } + // Merge host metadata from decorator into the existing host metadata if let Some(decorator_host) = host_from_decorator { // Merge properties @@ -466,8 +477,28 @@ fn has_ng_on_changes_method(class: &Class<'_>) -> bool { /// the official Angular compiler's compile-time constant folding. /// /// Only literal string values (string literals and single-quasi template literals) -/// are captured; computed initializers and cross-file imports are out of scope. -pub type StringConsts<'a> = HashMap<&'a str, Ident<'a>>; +/// are folded; computed initializers and cross-file imports are out of scope. +/// It also carries the file's top-level declarations, for evaluating +/// `inputs:`, `outputs:` and `queries:` the way ngtsc's partial evaluator does. +#[derive(Default)] +pub struct StringConsts<'a> { + strings: HashMap<&'a str, Ident<'a>>, + program: Option<&'a Program<'a>>, + /// Built on first use: most files have no decorator metadata to evaluate. + scope: std::cell::OnceCell>, +} + +impl<'a> StringConsts<'a> { + /// The folded string value of a same-file `const`. + pub fn get(&self, name: &str) -> Option<&Ident<'a>> { + self.strings.get(name) + } + + /// The file's top-level declarations, for the partial evaluator. + pub(crate) fn scope(&self) -> &FileScope<'a> { + self.scope.get_or_init(|| self.program.map(FileScope::collect).unwrap_or_default()) + } +} /// Walk the top-level statements of a program and collect string-valued `const` /// declarations. @@ -480,12 +511,12 @@ pub type StringConsts<'a> = HashMap<&'a str, Ident<'a>>; /// matching the official Angular compiler's partial evaluator. pub fn collect_string_consts<'a>( allocator: &'a Allocator, - program: &Program<'a>, + program: &'a Program<'a>, ) -> StringConsts<'a> { // Collect every top-level `const` binding's name + initializer up front so // we can iterate to a fixed point. Each pending entry is dropped from the // worklist as soon as its initializer folds successfully. - let mut pending: std::vec::Vec<(&'a str, &Expression<'a>)> = std::vec::Vec::new(); + let mut pending: std::vec::Vec<(&'a str, &'a Expression<'a>)> = std::vec::Vec::new(); for stmt in &program.body { let decl = match stmt { Statement::VariableDeclaration(d) => d.as_ref(), @@ -507,21 +538,25 @@ pub fn collect_string_consts<'a>( } } - let mut map = StringConsts::default(); + let mut map = StringConsts { + strings: HashMap::default(), + program: Some(program), + scope: std::cell::OnceCell::new(), + }; loop { - let before = map.len(); + let before = map.strings.len(); pending.retain(|(name, init)| { - if map.contains_key(name) { + if map.strings.contains_key(name) { return false; } if let Some(value) = extract_string_value(allocator, init, &map) { - map.insert(name, value); + map.strings.insert(name, value); false } else { true } }); - if map.len() == before { + if map.strings.len() == before { break; } } @@ -609,6 +644,271 @@ fn extract_boolean_value(expr: &Expression<'_>) -> Option { } } +/// `inputs:` / `outputs:` declared in a `@Directive` / `@Component` metadata object. +pub(crate) struct DecoratorIo<'a> { + pub inputs: Vec<'a, R3InputMetadata<'a>>, + /// (class property name, binding property name) + pub outputs: Vec<'a, (Ident<'a>, Ident<'a>)>, + /// The first error ngtsc reports for `inputs:`, then for `outputs:`. + pub input_error: Option, + pub output_error: Option, +} + +/// The last property called `name` in a decorator metadata object. +pub(super) fn config_property<'a>( + config: &'a ObjectExpression<'a>, + name: &str, + consts: &StringConsts<'a>, +) -> Option<&'a Expression<'a>> { + config.properties.iter().rev().find_map(|prop| match prop { + ObjectPropertyKind::ObjectProperty(prop) + if get_property_key_name(&prop.key, consts).is_some_and(|k| k == name) => + { + Some(&prop.value) + } + _ => None, + }) +} + +/// Parse `inputs:` / `outputs:` from a decorator metadata object. +/// +/// Reference: `parseInputsArray` / `parseOutputsArray` in +/// packages/compiler-cli/src/ngtsc/annotations/directive/src/shared.ts +pub(crate) fn parse_decorator_io<'a>( + allocator: &'a Allocator, + config: &'a ObjectExpression<'a>, + source_text: Option<&'a str>, + consts: &StringConsts<'a>, +) -> DecoratorIo<'a> { + let evaluator = Evaluator::new(consts); + let alloc = |s: &str| Ident::from(allocator.alloc_str(s)); + let mut io = DecoratorIo { + inputs: Vec::new_in(&allocator), + outputs: Vec::new_in(&allocator), + input_error: None, + output_error: None, + }; + + if let Some(expr) = config_property(config, "inputs", consts) { + match evaluator.evaluate(expr) { + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + let error = match item { + Value::String(s) => { + let (class_name, binding_name) = parse_mapping_string(s); + upsert_input( + &mut io.inputs, + R3InputMetadata { + binding_property_name: alloc(binding_name), + ..R3InputMetadata::simple(alloc(class_name)) + }, + ); + None + } + Value::Object(_) => { + parse_input_object(allocator, &mut io, item, i, source_text) + } + other => Some(format!( + "@Directive.inputs array can only contain strings or object literals{}", + other.wrong_type_suffix() + )), + }; + io.input_error = io.input_error.take().or(error); + } + } + other => { + io.input_error = Some(format!( + "Failed to resolve @Directive.inputs to an array{}", + other.wrong_type_suffix() + )); + } + } + } + + if let Some(expr) = config_property(config, "outputs", consts) { + match evaluator.evaluate(expr) { + Value::Array(items) => { + for (i, item) in items.iter().enumerate() { + match item { + Value::String(s) => { + let (class_name, binding_name) = parse_mapping_string(s); + upsert( + &mut io.outputs, + (alloc(class_name), alloc(binding_name)), + |o| o.0.as_str(), + ); + } + other => { + io.output_error = io.output_error.take().or_else(|| { + Some(format!( + "Failed to resolve outputs at position {i} to a string{}", + other.wrong_type_suffix() + )) + }); + } + } + } + } + other => { + io.output_error = Some(format!( + "Failed to resolve @Directive.outputs to a string array{}", + other.wrong_type_suffix() + )); + } + } + } + io +} + +/// `'field'` or `'field: binding'` -> (class property name, binding property name). +/// Like ngtsc's `value.split(':', 2)`, anything after a second colon is ignored. +fn parse_mapping_string(value: &str) -> (&str, &str) { + let mut parts = value.split(':').map(str::trim); + let field = parts.next().unwrap_or_default(); + (field, parts.next().unwrap_or(field)) +} + +/// `{ name, alias?, required?, transform? }` at `position` in the `inputs:` array. +/// Returns ngtsc's error, if any. +fn parse_input_object<'a>( + allocator: &'a Allocator, + io: &mut DecoratorIo<'a>, + item: &Value<'a>, + position: usize, + source_text: Option<&'a str>, +) -> Option { + let name = match item.prop("name").map(|p| &p.value) { + Some(Value::String(name)) => name.as_str(), + other => { + return Some(format!( + "Value at position {position} of @Directive.inputs array must have a \"name\" property{}", + other.unwrap_or(&Value::Undefined).wrong_type_suffix() + )); + } + }; + 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 = item + .prop("transform") + .and_then(|t| convert_oxc_expression(allocator, t.expr?, source_text)); + upsert_input( + &mut io.inputs, + R3InputMetadata { + class_property_name: Ident::from(allocator.alloc_str(name)), + binding_property_name: Ident::from(allocator.alloc_str(alias)), + required, + is_signal: false, + transform_function, + }, + ); + None +} + +/// ngtsc's `{...fromMeta, ...fromFields}` keyed by class property name: a member +/// declaration replaces the metadata entry in place, new members are appended. +pub(crate) fn merge_by_class_property( + mut from_meta: Vec<'_, T>, + from_fields: impl IntoIterator, + key: impl Fn(&T) -> &str, +) -> Vec<'_, T> { + for field in from_fields { + upsert(&mut from_meta, field, &key); + } + from_meta +} + +/// Insert keyed by class property name, like assigning to a JS object: a +/// repeated key keeps its first position and takes the later value. +fn upsert(list: &mut Vec<'_, T>, item: T, key: impl Fn(&T) -> &str) { + match list.iter().position(|existing| key(existing) == key(&item)) { + Some(i) => list[i] = item, + None => list.push(item), + } +} + +fn upsert_input<'a>(inputs: &mut Vec<'a, R3InputMetadata<'a>>, input: R3InputMetadata<'a>) { + upsert(inputs, input, |i| i.class_property_name.as_str()); +} + +/// The `@Component` / `@Directive` decorator on `class`, its metadata object +/// (if any) and its name. +pub(crate) fn angular_decorator_config<'a>( + class: &'a Class<'a>, +) -> Option<(Option<&'a ObjectExpression<'a>>, &'static str)> { + let (decorator, name) = crate::component::find_component_decorator(&class.decorators) + .map(|d| (d, "Component")) + .or_else(|| find_directive_decorator(&class.decorators).map(|d| (d, "Directive")))?; + let config = match &decorator.expression { + Expression::CallExpression(call) => match call.arguments.first() { + Some(Argument::ObjectExpression(config)) => Some(&**config), + _ => None, + }, + _ => None, + }; + Some((config, name)) +} + +/// 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 +/// output members. ngtsc stops at the first one. +pub fn decorator_io_errors<'a>( + allocator: &'a Allocator, + class: &'a Class<'a>, + consts: &StringConsts<'a>, +) -> std::vec::Vec { + 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 (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(), + io.outputs.iter().map(|o| o.0.as_str()).collect(), + ), + None => Default::default(), + }; + let input_members = || { + class.body.body.iter().find_map(|element| { + let ClassElement::PropertyDefinition(prop) = element else { return None }; + let name = prop.key.static_name()?; + // 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 signal = Ident::from(allocator.alloc_str(&name)); + let is_input = super::try_parse_signal_model(allocator, value, signal.clone()) + .is_some() + || super::try_parse_signal_input(allocator, value, signal).is_some(); + is_input.then(|| { + format!("Input \"{name}\" is also declared as non-signal in @{decorator_name}.") + }) + }) + }; + let output_members = || { + class.body.body.iter().find_map(|element| { + let ClassElement::PropertyDefinition(prop) = element else { return None }; + let (value, name) = (prop.value.as_ref()?, prop.key.static_name()?); + if !meta_outputs.contains(&name.as_ref()) { + return None; + } + let signal = Ident::from(allocator.alloc_str(&name)); + let is_output = super::try_parse_signal_model(allocator, value, signal.clone()) + .is_some() + || super::try_parse_signal_output(value, signal).is_some(); + is_output.then(|| { + format!("Output \"{name}\" is unexpectedly declared in @{decorator_name} as well.") + }) + }) + }; + + io.as_ref() + .and_then(|io| io.input_error.clone()) + .or_else(input_members) + .or_else(|| io.as_ref().and_then(|io| io.output_error.clone())) + .or_else(output_members) + .into_iter() + .collect() +} + /// Extract host metadata from a host object expression. /// /// Reference: packages/compiler/src/render3/view/compiler.ts:560-604 diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs new file mode 100644 index 000000000..a9af95e3b --- /dev/null +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -0,0 +1,475 @@ +//! A single-file model of ngtsc's partial evaluator. +//! +//! Decorator metadata such as `inputs:`, `outputs:` and `queries:` is read by +//! ngtsc through its partial evaluator +//! (packages/compiler-cli/src/ngtsc/partial_evaluator), and its diagnostics +//! describe values the way that evaluator resolves them. This module reproduces +//! the parts of it that can be answered from the current file, so the compiled +//! metadata and the diagnostics match ngtsc word for word. Imported bindings +//! can't be seen into and are treated as opaque references. + +use std::collections::{HashMap, HashSet}; + +use oxc_ast::ast::{ + ArrayExpressionElement, BindingPattern, Class, ClassElement, Declaration, + ExportDefaultDeclarationKind, Expression, Function, ImportDeclarationSpecifier, + MethodDefinitionKind, ModuleExportName, ObjectPropertyKind, Program, PropertyKey, Statement, +}; + +use crate::output::emitter::format_number_like_js; + +/// Everything declared at the top level of a file that the evaluator can resolve. +#[derive(Default)] +pub(crate) struct FileScope<'a> { + /// `const`/`let`/`var` bindings with an initializer. + variables: HashMap<&'a str, &'a Expression<'a>>, + /// Bindings with no initializer (`declare const X: T`, `let x;`), functions + /// and enums: references to a declaration that isn't evaluated. + declared: HashSet<&'a str>, + classes: HashMap<&'a str, &'a Class<'a>>, + imports: HashMap<&'a str, Import<'a>>, + /// Names exported from the file (`export ...` and `export { ... }`). + exported: HashSet<&'a str>, + /// Interfaces, type aliases, classes and enums declared in the file. + types: HashSet<&'a str>, +} + +/// An import binding: unless it's a namespace import, the name it's exported under. +#[derive(Clone, Copy)] +pub(crate) struct Import<'a> { + pub imported: Option<&'a str>, +} + +impl<'a> FileScope<'a> { + pub(crate) fn collect(program: &'a Program<'a>) -> Self { + let mut scope = Self::default(); + for stmt in &program.body { + match stmt { + Statement::ImportDeclaration(import) => { + for spec in import.specifiers.iter().flatten() { + let (local, imported) = match spec { + ImportDeclarationSpecifier::ImportSpecifier(s) => { + let imported = match &s.imported { + ModuleExportName::IdentifierName(n) => n.name.as_str(), + ModuleExportName::IdentifierReference(n) => n.name.as_str(), + ModuleExportName::StringLiteral(n) => n.value.as_str(), + }; + (s.local.name.as_str(), Some(imported)) + } + ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => { + (s.local.name.as_str(), Some("default")) + } + ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + (s.local.name.as_str(), None) + } + }; + scope.imports.insert(local, Import { imported }); + } + } + Statement::ExportDeclaration(export) => { + scope.declaration(&export.declaration, true); + } + Statement::ExportNamedDeclaration(export) => { + for spec in &export.specifiers { + if let ModuleExportName::IdentifierReference(local) = &spec.local { + scope.exported.insert(local.name.as_str()); + } + } + } + Statement::ExportDefaultDeclaration(export) => match &export.declaration { + ExportDefaultDeclarationKind::ClassDeclaration(class) => { + scope.class(class, true); + } + ExportDefaultDeclarationKind::FunctionDeclaration(function) => { + scope.function(function, true); + } + _ => {} + }, + _ => { + if let Some(decl) = stmt.as_declaration() { + scope.declaration(decl, false); + } + } + } + } + scope + } + + fn declaration(&mut self, decl: &'a Declaration<'a>, exported: bool) { + let name = |scope: &mut Self, name: &'a str| { + if exported { + scope.exported.insert(name); + } + }; + match decl { + Declaration::VariableDeclaration(vars) => { + for var in &vars.declarations { + let BindingPattern::BindingIdentifier(id) = &var.id else { continue }; + let id = id.name.as_str(); + match &var.init { + Some(init) => { + self.variables.insert(id, init); + } + None => { + self.declared.insert(id); + } + } + name(self, id); + } + } + Declaration::FunctionDeclaration(function) => self.function(function, exported), + Declaration::ClassDeclaration(class) => self.class(class, exported), + Declaration::TSEnumDeclaration(e) => { + let id = e.id.name.as_str(); + // An enum isn't evaluated; it's a non-function reference. + self.declared.insert(id); + self.types.insert(id); + name(self, id); + } + Declaration::TSInterfaceDeclaration(i) => { + self.types.insert(i.id.name.as_str()); + name(self, i.id.name.as_str()); + } + Declaration::TSTypeAliasDeclaration(t) => { + self.types.insert(t.id.name.as_str()); + name(self, t.id.name.as_str()); + } + _ => {} + } + } + + fn function(&mut self, function: &'a Function<'a>, exported: bool) { + let Some(id) = &function.id else { return }; + let id = id.name.as_str(); + self.declared.insert(id); + if exported { + self.exported.insert(id); + } + } + + fn class(&mut self, class: &'a Class<'a>, exported: bool) { + let Some(id) = &class.id else { return }; + let id = id.name.as_str(); + self.classes.insert(id, class); + self.types.insert(id); + if exported { + self.exported.insert(id); + } + } +} + +/// What a declaration reference resolves to. +#[derive(Clone)] +pub(crate) enum RefKind<'a> { + Class(&'a Class<'a>), + /// An imported binding, or `ns.x` through `import * as ns`. + Import, + /// An identifier with no declaration in this file (a global, most likely). + Global, + /// Any other declaration (a variable without an initializer, ...). + Other, +} + +/// A value as ngtsc's partial evaluator resolves it. +#[derive(Clone)] +pub(crate) enum Value<'a> { + Null, + Undefined, + Bool(bool), + Number(f64), + String(String), + Array(Vec>), + Object(Vec>), + /// `import * as ns`. + Module, + Reference { + name: String, + kind: RefKind<'a>, + }, + Dynamic, +} + +/// An object literal property: its key, value, and the source expression it came from. +#[derive(Clone)] +pub(crate) struct Prop<'a> { + pub key: String, + pub value: Value<'a>, + pub expr: Option<&'a Expression<'a>>, +} + +impl<'a> Value<'a> { + pub(crate) fn prop(&self, key: &str) -> Option<&Prop<'a>> { + match self { + Value::Object(props) => props.iter().rev().find(|p| p.key == key), + _ => None, + } + } + + pub(crate) fn as_str(&self) -> Option<&str> { + match self { + Value::String(s) => Some(s), + _ => None, + } + } + + /// ngtsc's `describeResolvedType`, one level deep like its diagnostics. + pub(crate) fn describe(&self) -> String { + self.describe_to(1) + } + + fn describe_to(&self, depth: u16) -> String { + match self { + Value::Null => "null".into(), + Value::Undefined => "undefined".into(), + Value::Bool(_) => "boolean".into(), + Value::Number(_) => "number".into(), + Value::String(_) => "string".into(), + Value::Object(_) if depth == 0 => "object".into(), + Value::Object(props) if props.is_empty() => "{}".into(), + Value::Object(props) => { + let mut seen = HashSet::new(); + // Later duplicates win but keep the first key's position, like a Map. + let entries: std::vec::Vec = props + .iter() + .filter(|p| seen.insert(p.key.as_str())) + .map(|p| { + let value = self.prop(&p.key).map_or(&p.value, |p| &p.value); + format!("{}: {}", quote_key(&p.key), value.describe_to(depth - 1)) + }) + .collect(); + format!("{{ {} }}", entries.join("; ")) + } + Value::Array(_) if depth == 0 => "Array".into(), + Value::Array(items) => format!( + "[{}]", + items + .iter() + .map(|v| v.describe_to(depth - 1)) + .collect::>() + .join(", ") + ), + Value::Module => "(module)".into(), + Value::Reference { name, .. } => name.clone(), + Value::Dynamic => "(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::Reference { name, .. } => format!(" Value is a reference to '{name}'."), + _ => format!(" Value is of type '{}'.", self.describe()), + } + } +} + +fn quote_key(key: &str) -> String { + if !key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + key.to_string() + } else { + format!("'{}'", key.replace('\'', "\\'")) + } +} + +/// Bounds expression nesting, to keep deeply nested literals off the end of the stack. +const MAX_DEPTH: u16 = 256; + +pub(crate) struct Evaluator<'s, 'a> { + /// The file's declarations are only looked at when an identifier is resolved. + consts: &'s super::StringConsts<'a>, + /// Top-level variables already evaluated, so a chain of consts that each + /// reference the previous one several times stays linear. `None` while a + /// variable is being evaluated, which makes a circular reference dynamic. + variables: std::cell::RefCell>>>, +} + +impl<'s, 'a> Evaluator<'s, 'a> { + pub(crate) fn new(consts: &'s super::StringConsts<'a>) -> Self { + Self { consts, variables: std::cell::RefCell::default() } + } + + pub(crate) fn evaluate(&self, expr: &'a Expression<'a>) -> Value<'a> { + self.eval(expr, 0) + } + + fn eval(&self, expr: &'a Expression<'a>, depth: u16) -> Value<'a> { + if depth > MAX_DEPTH { + return Value::Dynamic; + } + let depth = depth + 1; + match expr { + Expression::NullLiteral(_) => Value::Null, + Expression::BooleanLiteral(b) => Value::Bool(b.value), + Expression::NumericLiteral(n) => Value::Number(n.value), + Expression::StringLiteral(s) => Value::String(s.value.to_string()), + Expression::TemplateLiteral(tpl) => { + let mut out = String::new(); + for (i, quasi) in tpl.quasis.iter().enumerate() { + let Some(cooked) = &quasi.value.cooked else { return Value::Dynamic }; + out.push_str(cooked); + if let Some(e) = tpl.expressions.get(i) { + match self.eval(e, depth) { + Value::String(s) => out.push_str(&s), + Value::Number(n) => out.push_str(&format_number_like_js(n)), + Value::Bool(b) => out.push_str(if b { "true" } else { "false" }), + Value::Null => out.push_str("null"), + Value::Undefined => out.push_str("undefined"), + _ => return Value::Dynamic, + } + } + } + Value::String(out) + } + Expression::Identifier(id) => self.identifier(id.name.as_str(), depth), + Expression::ParenthesizedExpression(e) => self.eval(&e.expression, depth), + Expression::TSAsExpression(e) => self.eval(&e.expression, depth), + Expression::TSSatisfiesExpression(e) => self.eval(&e.expression, depth), + Expression::TSNonNullExpression(e) => self.eval(&e.expression, depth), + Expression::TSTypeAssertion(e) => self.eval(&e.expression, depth), + Expression::ArrayExpression(arr) => { + let mut items = std::vec::Vec::new(); + for el in &arr.elements { + match el { + ArrayExpressionElement::SpreadElement(spread) => { + match self.eval(&spread.argument, depth) { + Value::Array(inner) => items.extend(inner), + _ => return Value::Dynamic, + } + } + ArrayExpressionElement::Elision(_) => items.push(Value::Dynamic), + _ => items.push(self.eval(el.to_expression(), depth)), + } + } + Value::Array(items) + } + Expression::ObjectExpression(obj) => { + let mut props = std::vec::Vec::new(); + for prop in &obj.properties { + match prop { + ObjectPropertyKind::ObjectProperty(p) => { + if p.method || !matches!(p.kind, oxc_ast::ast::PropertyKind::Init) { + return Value::Dynamic; + } + let Some(key) = self.property_key(&p.key, depth) else { + return Value::Dynamic; + }; + let value = self.eval(&p.value, depth); + props.push(Prop { key, value, expr: Some(&p.value) }); + } + ObjectPropertyKind::SpreadProperty(spread) => { + match self.eval(&spread.argument, depth) { + Value::Object(inner) => props.extend(inner), + _ => return Value::Dynamic, + } + } + } + } + Value::Object(props) + } + Expression::StaticMemberExpression(m) => { + let object = self.eval(&m.object, depth); + self.member(object, m.property.name.as_str(), depth) + } + Expression::ComputedMemberExpression(m) => { + let object = self.eval(&m.object, depth); + match self.eval(&m.expression, depth) { + Value::String(key) => self.member(object, &key, depth), + Value::Number(n) => self.member(object, &format_number_like_js(n), depth), + _ => Value::Dynamic, + } + } + _ => Value::Dynamic, + } + } + + fn property_key(&self, key: &'a PropertyKey<'a>, depth: u16) -> Option { + match key { + PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()), + PropertyKey::PrivateIdentifier(_) => None, + key => match self.eval(key.to_expression(), depth) { + Value::String(s) => Some(s), + Value::Number(n) => Some(format_number_like_js(n)), + _ => None, + }, + } + } + + fn identifier(&self, name: &'a str, depth: u16) -> Value<'a> { + let scope = self.consts.scope(); + if let Some(init) = scope.variables.get(name) { + if let Some(cached) = self.variables.borrow().get(name) { + return cached.clone().unwrap_or(Value::Dynamic); + } + self.variables.borrow_mut().insert(name, None); + let value = self.eval(init, depth); + self.variables.borrow_mut().insert(name, Some(value.clone())); + return value; + } + if let Some(class) = scope.classes.get(name) { + return Value::Reference { name: name.into(), kind: RefKind::Class(class) }; + } + if let Some(import) = scope.imports.get(name) { + return match import.imported { + Some(imported) => { + let name = if imported == "default" { name } else { imported }; + Value::Reference { name: name.into(), kind: RefKind::Import } + } + None => Value::Module, + }; + } + if scope.declared.contains(name) { + return Value::Reference { name: name.into(), kind: RefKind::Other }; + } + match name { + "undefined" => Value::Undefined, + _ => Value::Reference { name: name.into(), kind: RefKind::Global }, + } + } + + fn member(&self, object: Value<'a>, key: &str, depth: u16) -> Value<'a> { + match object { + Value::Object(props) => { + props.into_iter().rev().find(|p| p.key == key).map_or(Value::Undefined, |p| p.value) + } + Value::Array(items) => match key { + "length" => Value::Number(items.len() as f64), + _ => key + .parse::() + .ok() + .and_then(|i| items.into_iter().nth(i)) + .unwrap_or(Value::Undefined), + }, + Value::String(s) if key == "length" => Value::Number(s.chars().count() as f64), + Value::Module => Value::Reference { name: key.into(), kind: RefKind::Import }, + Value::Reference { kind: RefKind::Class(class), .. } => { + self.static_member(class, key, depth) + } + Value::Reference { kind: RefKind::Import | RefKind::Global, .. } => { + Value::Reference { name: key.into(), kind: RefKind::Global } + } + _ => Value::Dynamic, + } + } + + fn static_member(&self, class: &'a Class<'a>, key: &str, depth: u16) -> Value<'a> { + for element in &class.body.body { + match element { + ClassElement::MethodDefinition(m) + if m.r#static + && m.kind == MethodDefinitionKind::Method + && m.key.static_name().is_some_and(|n| n == key) => + { + return Value::Reference { name: key.into(), kind: RefKind::Other }; + } + ClassElement::PropertyDefinition(p) + if p.r#static && p.key.static_name().is_some_and(|n| n == key) => + { + return p.value.as_ref().map_or(Value::Undefined, |v| self.eval(v, depth)); + } + _ => {} + } + } + if key == "prototype" { Value::Dynamic } else { Value::Undefined } + } +} diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index 279208720..71aab3981 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -15,6 +15,7 @@ mod compiler; mod decorator; mod definition; +mod evaluator; mod metadata; mod property_decorators; mod query; @@ -26,9 +27,11 @@ pub use compiler::{ }; pub(crate) use decorator::find_directive_decorator; pub use decorator::{ - StringConsts, collect_string_consts, extract_directive_metadata, find_directive_decorator_span, + StringConsts, collect_string_consts, decorator_io_errors, extract_directive_metadata, + 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 use definition::{DirectiveDefinitions, generate_directive_definitions}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, diff --git a/crates/oxc_angular_compiler/src/output/emitter.rs b/crates/oxc_angular_compiler/src/output/emitter.rs index 545731069..8dd572d16 100644 --- a/crates/oxc_angular_compiler/src/output/emitter.rs +++ b/crates/oxc_angular_compiler/src/output/emitter.rs @@ -1322,7 +1322,7 @@ fn escape_identifier(input: &Ident<'_>, escape_dollar: bool, always_quote: bool) /// This function matches the ECMAScript specification for `Number::toString()` /// (ECMA-262, 7.1.12.1) to ensure the emitted code matches Angular's TypeScript compiler /// which uses JavaScript's template literal coercion (`${value}`). -fn format_number_like_js(value: f64) -> String { +pub(crate) fn format_number_like_js(value: f64) -> String { // 1. NaN if value.is_nan() { return "NaN".to_string(); diff --git a/crates/oxc_angular_compiler/tests/decorator_inputs_outputs_test.rs b/crates/oxc_angular_compiler/tests/decorator_inputs_outputs_test.rs new file mode 100644 index 000000000..61b62cff8 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/decorator_inputs_outputs_test.rs @@ -0,0 +1,61 @@ +//! `inputs:` declared in `@Directive` metadata were silently dropped from the +//! compiled definition. This is the original report; exhaustive parity with +//! ngtsc for `inputs:` / `outputs:` / `queries:` metadata is checked by +//! `decorator_metadata_ngtsc_test.rs`. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +/// The whitespace-free `ɵɵdefineDirective` call for `class_name`. +fn define_call(code: &str, class_name: &str) -> String { + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let start = compact.find(&format!("({{type:{class_name},")).expect("define call"); + let end = compact[start..].find("});").map_or(compact.len(), |i| start + i); + compact[start..end].to_string() +} + +#[test] +fn inputs_declared_in_directive_metadata_are_compiled() { + // The report used `const bool = (v) => ...`; ngtsc (and oxc) reject a + // function expression assigned to a variable as a transform, so this uses a + // function declaration. + let source = "import { Directive, Input } from '@angular/core'; + +export function bool(v: unknown): boolean { return v === '' || v === true; } + +@Directive({ + selector: 'a', + inputs: ['x', { name: 'y', alias: 'why', transform: bool }], +}) +class A { + x?: string; + y?: boolean; +} + +@Directive() +abstract class B { + @Input() p?: string; + @Input({ alias: 'q-q', transform: bool }) q?: boolean; +} + +@Directive({ selector: 'c' }) +class C extends B {} +"; + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "test.ts", + source, + Some(&TransformOptions::default()), + None, + ); + assert!(!result.has_errors(), "{:?}", result.diagnostics); + // ngtsc: `inputs: { x: "x", y: [2, "why", "y", bool] }` + assert!( + define_call(&result.code, "A").contains(r#"inputs:{x:"x",y:[2,"why","y",bool]}"#), + "{}", + define_call(&result.code, "A") + ); + assert!(define_call(&result.code, "B").contains(r#"inputs:{p:"p",q:[2,"q-q","q",bool]}"#)); + assert!(define_call(&result.code, "C").contains("features:[i0.ɵɵInheritDefinitionFeature]")); +} diff --git a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs new file mode 100644 index 000000000..a8b324f4e --- /dev/null +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -0,0 +1,234 @@ +//! Parity with ngtsc for `inputs:` / `outputs:` / `queries:` decorator metadata +//! and input transforms. +//! +//! `fixtures/decorator_metadata_ngtsc.json` is a snapshot of ngtsc's output +//! (`@angular/compiler-cli` 22.1.7) for each case. Each case's `origin` says +//! where it comes from: an Angular spec (sources taken verbatim from +//! angular/angular) or a probe of ngtsc's partial evaluator and `.d.ts` +//! printer. Cases ngtsc and oxc can't agree on by design carry a `skip` +//! reason (mostly: they need declarations from another file). For every case this +//! compares the diagnostics word for word and, when ngtsc accepts the file, +//! each class's `inputs`/`outputs` maps and query functions (ignoring +//! whitespace and parentheses, which the two emitters place differently) and +//! its `.d.ts` members (ignoring whitespace, whose layout TypeScript's printer +//! derives from source positions). +//! +//! One deliberate difference: when ngtsc types an `ngAcceptInputType_*` with a +//! type from another module (`i1.Foo`, plus an `import * as i1` it adds to the +//! `.d.ts`), oxc writes `unknown`. Aliases numbered per source file can't be +//! merged safely into bundled declaration files; `i0` (`@angular/core`) is +//! always imported, so those types are kept. + +use std::collections::BTreeMap; + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; +use serde_json::Value; + +const FIXTURES: &str = include_str!("fixtures/decorator_metadata_ngtsc.json"); + +fn strip(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace()).collect() +} + +fn strip_parens(s: &str) -> String { + s.chars().filter(|c| *c != '(' && *c != ')').collect() +} + +/// The balanced `{...}` / `(...)` / `[...]` starting at byte `open`. +fn balanced(text: &str, open: usize) -> &str { + let bytes = text.as_bytes(); + let mut stack = Vec::new(); + let mut i = open; + while i < bytes.len() { + match bytes[i] { + q @ (b'"' | b'\'' | b'`') => { + i += 1; + while i < bytes.len() && bytes[i] != q { + i += if bytes[i] == b'\\' { 2 } else { 1 }; + } + } + b'{' => stack.push(b'}'), + b'(' => stack.push(b')'), + b'[' => stack.push(b']'), + c if stack.last() == Some(&c) => { + stack.pop(); + if stack.is_empty() { + return &text[open..=i]; + } + } + _ => {} + } + i += 1; + } + &text[open..] +} + +/// ngtsc adds a docs link to a few diagnostics when it formats them; the link +/// depends on the Angular release, so it isn't part of the comparison. +fn without_docs_link(message: &str) -> &str { + let Some(at) = message.find(" Find more at https://") else { return message }; + message[..at].strip_suffix('.').unwrap_or(&message[..at]) +} + +/// Per class, what the fixture records: `inputs`/`outputs`/`contentQueries`/ +/// `viewQuery` from the JS (whitespace removed, `_cN` constants inlined) and +/// `dts:` from the `.d.ts`. +fn oxc_classes( + js: &str, + dts: &[oxc_angular_compiler::dts::DtsDeclaration], +) -> BTreeMap> { + let mut consts = BTreeMap::new(); + for (at, _) in js.match_indices("const _c") { + let rest = &js[at + 6..]; + let Some(eq) = rest.find(" = ") else { continue }; + let name = rest[..eq].to_string(); + consts.insert(name, strip(balanced(rest, eq + 3))); + } + let inline = |s: String| { + let mut out = s; + // Longest names first so `_c10` isn't replaced as `_c1` + `0`. + let mut names: Vec<&String> = consts.keys().collect(); + names.sort_by_key(|n| std::cmp::Reverse(n.len())); + for name in names { + out = out.replace(name.as_str(), &consts[name]); + } + out + }; + + let mut classes: BTreeMap> = BTreeMap::new(); + for marker in ["ɵɵdefineDirective(", "ɵɵdefineComponent("] { + for (at, _) in js.match_indices(marker) { + let def = balanced(js, at + marker.len() - 1); + let compact = strip(def); + let Some(name) = compact + .strip_prefix("({type:") + .map(|r| r.split(|c: char| !c.is_alphanumeric() && c != '_').next().unwrap()) + else { + continue; + }; + let entry = classes.entry(name.to_string()).or_default(); + for key in ["inputs", "outputs"] { + if let Some(pos) = compact.find(&format!(",{key}:{{")) { + let open = pos + key.len() + 2; + entry.insert(key.into(), balanced(&compact, open).to_string()); + } + } + for key in ["contentQueries", "viewQuery"] { + if let Some(pos) = def + .find(&format!("{key}: function")) + .or_else(|| def.find(&format!("{key}:function"))) + { + let params_end = pos + def[pos..].find(')').unwrap(); + let open = params_end + def[params_end..].find('{').unwrap(); + entry.insert(key.into(), inline(strip(balanced(def, open)))); + } + } + } + } + for decl in dts { + let entry = classes.entry(decl.class_name.clone()).or_default(); + for line in decl.members.lines() { + let line = line.trim(); + let Some(rest) = line.strip_prefix("static ") else { continue }; + let member = rest.split(':').next().unwrap().trim_matches('"'); + if member == "ɵdir" || member == "ɵcmp" || member.starts_with("ngAcceptInputType_") { + entry.insert(format!("dts:{member}"), strip(line)); + } + } + } + classes +} + +/// `staticngAcceptInputType_x:;` becomes `...:unknown;` when `` +/// references a module other than `@angular/core` (see the module docs). +fn accept_type_without_other_modules(key: &str, expected: &str) -> String { + let other_module = expected.match_indices('i').any(|(at, _)| { + let before = expected[..at].chars().last(); + let digits: String = expected[at + 1..].chars().take_while(char::is_ascii_digit).collect(); + !before.is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$') + && !digits.is_empty() + && digits != "0" + && expected[at + 1 + digits.len()..].starts_with('.') + }); + if key.starts_with("dts:ngAcceptInputType_") && other_module { + let colon = expected.find(':').unwrap(); + format!("{}:unknown;", &expected[..colon]) + } else { + expected.to_string() + } +} + +#[test] +fn decorator_metadata_matches_ngtsc() { + let fixtures: Value = serde_json::from_str(FIXTURES).unwrap(); + let mut failures = Vec::new(); + let mut compared = 0; + + for fixture in fixtures["fixtures"].as_array().unwrap() { + if fixture.get("skip").is_some() { + continue; + } + compared += 1; + let name = fixture["name"].as_str().unwrap(); + let source = fixture["files"]["test.ts"].as_str().unwrap(); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "test.ts", + source, + Some(&TransformOptions::default()), + None, + ); + + let expected: Vec<&str> = fixture["diagnostics"] + .as_array() + .unwrap() + .iter() + .map(|d| without_docs_link(d.as_str().unwrap())) + .collect(); + let actual: Vec = result + .diagnostics + .iter() + .filter(|d| d.severity == oxc_diagnostics::Severity::Error) + .map(|d| d.message.to_string()) + .collect(); + if actual != expected { + failures.push(format!( + "{name}\n diagnostics\n ngtsc: {expected:?}\n oxc: {actual:?}" + )); + } + + // ngtsc emits nothing for a class it rejects. + if !expected.is_empty() { + continue; + } + let oxc = oxc_classes(&result.code, &result.dts_declarations); + for (class, members) in fixture["classes"].as_object().unwrap() { + for (key, value) in members.as_object().unwrap() { + let expected = accept_type_without_other_modules(key, value.as_str().unwrap()); + let expected = expected.as_str(); + let actual = + oxc.get(class).and_then(|m| m.get(key)).map_or("", String::as_str); + let same = if key.starts_with("dts:") { + actual == expected + } else { + strip_parens(actual) == strip_parens(expected) + }; + if !same { + failures.push(format!( + "{name}\n {class}.{key}\n ngtsc: {expected}\n oxc: {actual}" + )); + } + } + } + } + + assert!( + failures.is_empty(), + "{} mismatches with ngtsc:\n\n{}", + failures.len(), + failures.join("\n\n") + ); + assert_eq!(compared, 45, "fixtures compared"); +} diff --git a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json new file mode 100644 index 000000000..ad3452dfa --- /dev/null +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -0,0 +1,681 @@ +{ + "compilerCli": "22.1.7", + "fixtures": [ + { + "name": "ngtsc_spec: should allow directives with no selector that are not in NgModules", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive} from '@angular/core';\n\n @Directive({})\n export class BaseDir {}\n\n @Directive({})\n export abstract class AbstractBaseDir {}\n\n @Directive()\n export abstract class EmptyDir {}\n\n @Directive({\n inputs: ['a', 'b'],\n standalone: false,\n })\n export class TestDirWithInputs {}\n " + }, + "diagnostics": [], + "classes": { + "BaseDir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + }, + "AbstractBaseDir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + }, + "EmptyDir": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + }, + "TestDirWithInputs": { + "inputs": "{a:\"a\",b:\"b\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should wrap \"inputs\" and \"outputs\" keys if they contain unsafe characters", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({\n selector: '[somedir]',\n inputs: ['track-type', 'track-name', 'inputTrackName', 'src.xl'],\n outputs: ['output-track-type', 'output-track-name', 'outputTrackName', 'output.event']\n })\n export class SomeDir {\n @Input('track-type') trackType: string;\n @Input('track-name') trackName: string;\n }\n " + }, + "diagnostics": [], + "classes": { + "SomeDir": { + "inputs": "{\"track-type\":\"track-type\",\"track-name\":\"track-name\",inputTrackName:\"inputTrackName\",\"src.xl\":\"src.xl\",trackType:[0,\"track-type\",\"trackType\"],trackName:[0,\"track-name\",\"trackName\"]}", + "outputs": "{\"output-track-type\":\"output-track-type\",\"output-track-name\":\"output-track-name\",outputTrackName:\"outputTrackName\",\"output.event\":\"output.event\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should generate the correct declaration for class members decorated with @Input", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({\n selector: '[dir]',\n standalone: false,\n })\n export class TestDir {\n @Input() noArgs: any;\n @Input('aliasedStringArg') stringArg: any;\n @Input({required: true}) requiredNoAlias: any;\n @Input({alias: 'aliasedRequiredWithAlias', required: true}) requiredWithAlias: any;\n }\n " + }, + "diagnostics": [], + "classes": { + "TestDir": { + "inputs": "{noArgs:\"noArgs\",stringArg:[0,\"aliasedStringArg\",\"stringArg\"],requiredNoAlias:\"requiredNoAlias\",requiredWithAlias:[0,\"aliasedRequiredWithAlias\",\"requiredWithAlias\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should generate the correct declaration for directives using the `inputs` array", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n @Directive({\n selector: '[dir]',\n inputs: [\n 'plain',\n 'withAlias: aliasedWithAlias',\n {name: 'plainLiteral'},\n {name: 'aliasedLiteral', alias: 'alisedLiteralAlias'},\n {name: 'requiredLiteral', required: true},\n {name: 'requiredAlisedLiteral', alias: 'requiredAlisedLiteralAlias', required: true}\n ],\n standalone: false,\n })\n export class TestDir {\n plainLiteral: any;\n aliasedLiteral: any;\n requiredLiteral: any;\n requiredAlisedLiteral: any;\n }\n " + }, + "diagnostics": [], + "classes": { + "TestDir": { + "inputs": "{plain:\"plain\",withAlias:[0,\"aliasedWithAlias\",\"withAlias\"],plainLiteral:\"plainLiteral\",aliasedLiteral:[0,\"alisedLiteralAlias\",\"aliasedLiteral\"],requiredLiteral:\"requiredLiteral\",requiredAlisedLiteral:[0,\"requiredAlisedLiteralAlias\",\"requiredAlisedLiteral\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should handle queries that use an InjectionToken", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Component, ContentChild, InjectionToken, ViewChild} from '@angular/core';\n\n const TOKEN = new InjectionToken('token');\n\n @Component({\n selector: 'test',\n template: '
',\n })\n class FooCmp {\n @ViewChild(TOKEN) viewChild: any;\n @ContentChild(TOKEN) contentChild: any;\n }\n " + }, + "diagnostics": [], + "classes": { + "FooCmp": { + "contentQueries": "{if(rf&1){i0.ɵɵcontentQuery(dirIndex,TOKEN,5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.contentChild=_t.first);}}", + "viewQuery": "{if(rf&1){i0.ɵɵviewQuery(TOKEN,5);}if(rf&2){let_t;i0.ɵɵqueryRefresh(_t=i0.ɵɵloadQuery())&&(ctx.viewChild=_t.first);}}" + } + }, + "dtsImports": [] + }, + { + "name": "ngtsc_spec: should compile an input with a transform function that contains a generic parameter", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "types.ts": "\n export interface GenericWrapper {\n value: T;\n }\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {GenericWrapper} from './types';\n\n function toNumber(value: boolean | string | GenericWrapper) { return 1; }\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{value:[2,\"value\",\"value\",toNumber]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_value": "staticngAcceptInputType_value:boolean|string|i1.GenericWrapper;" + } + }, + "dtsImports": ["import * as i1 from \"./types\";"] + }, + { + "name": "ngtsc_spec: should compile an input with a transform function that contains nested generic parameters", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "types.ts": "\n export interface GenericWrapper {\n value: T;\n }\n ", + "other-types.ts": "\n export class GenericClass {\n foo: T;\n }\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {GenericWrapper} from './types';\n import {GenericClass} from './other-types';\n\n function toNumber(value: boolean | string | GenericWrapper>) { return 1; }\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{value:[2,\"value\",\"value\",toNumber]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_value": "staticngAcceptInputType_value:boolean|string|i1.GenericWrapper>;" + } + }, + "dtsImports": ["import * as i1 from \"./types\";", "import * as i2 from \"./other-types\";"] + }, + { + "name": "ngtsc_spec: should compile an input with an inline transform function", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "node_modules/external/index.d.ts": "\n export interface ExternalObj {\n foo: boolean;\n }\n\n export type ExternalToNumberType = string | boolean | ExternalObj;\n ", + "test.ts": "\n import {Directive, Input} from '@angular/core';\n import {ExternalToNumberType} from 'external';\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: (value: ExternalToNumberType) => value ? 1 : 0}) value!: number;\n }\n " + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{value:[2,\"value\",\"value\",(value)=>value?1:0]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_value": "staticngAcceptInputType_value:i1.ExternalToNumberType;" + } + }, + "dtsImports": ["import * as i1 from \"external\";"] + }, + { + "name": "ngtsc_spec: should treat an input transform function only with a `this` parameter as unknown", + "origin": "packages/compiler-cli/test/ngtsc/ngtsc_spec.ts", + "files": { + "test.ts": "\n import {Directive, Input} from '@angular/core';\n\n function toNumber(this: Dir) { return 1; }\n\n @Directive({standalone: true})\n export class Dir {\n @Input({transform: toNumber}) value!: number;\n }\n " + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{value:[2,\"value\",\"value\",toNumber]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_value": "staticngAcceptInputType_value:unknown;" + } + }, + "dtsImports": [] + }, + { + "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", + "files": { + "test.ts": "\n import {Directive, input} from '@angular/core';\n\n @Directive({\n inputs: ['data'],\n })\n export class TestDir {\n data = input('test');\n }\n " + }, + "diagnostics": ["Input \"data\" is also declared as non-signal in @Directive."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "authoring_outputs_spec: should fail if used with output declared in @Directive metadata", + "origin": "packages/compiler-cli/test/ngtsc/authoring_outputs_spec.ts", + "files": { + "test.ts": "\n import {Directive, Output, output} from '@angular/core';\n\n @Directive({\n selector: 'test',\n outputs: ['click'],\n })\n export class TestDir {\n click = output({alias: 'publicClick'});\n }\n " + }, + "diagnostics": ["Output \"click\" is unexpectedly declared in @Directive as well."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "authoring_outputs_spec: should fail if used with output declared in @Component metadata", + "origin": "packages/compiler-cli/test/ngtsc/authoring_outputs_spec.ts", + "files": { + "test.ts": "\n import {Component, Output, output} from '@angular/core';\n\n @Component({\n selector: 'test',\n template: '',\n outputs: ['click'],\n })\n export class TestDir {\n click = output({alias: 'publicClick'});\n }\n " + }, + "diagnostics": ["Output \"click\" is unexpectedly declared in @Component as well."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "authoring_models_spec: should report a diagnostic if a model input is also declared in the `inputs` field", + "origin": "packages/compiler-cli/test/ngtsc/authoring_models_spec.ts", + "files": { + "test.ts": "\n import {Directive, model} from '@angular/core';\n\n @Directive({\n inputs: ['value'],\n })\n export class TestDir {\n value = model('test');\n }\n " + }, + "diagnostics": ["Input \"value\" is also declared as non-signal in @Directive."], + "classes": {}, + "dtsImports": [] + }, + { + "name": "compliance/r3_view_compiler_bindings/order_bindings", + "origin": "packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/order_bindings.ts", + "files": { + "test.ts": "import {Component} from '@angular/core';\n\n@Component({\n selector: 'some-elem',\n template: ``,\n inputs: ['attr1', 'prop1', 'attrInterp1', 'propInterp1'],\n})\nexport class SomeCmp {}\n\n@Component({\n selector: 'my-cmp',\n imports: [SomeCmp],\n host: {\n 'literal1': 'foo',\n '(event1)': 'foo()',\n '[attr.attr1]': 'foo',\n '[id]': 'foo',\n '[class.class1]': 'false',\n '[style.style1]': 'true',\n '[class]': 'foo',\n '[style]': 'foo',\n },\n template: `\n\t\t\n\t`,\n})\nexport class MyCmp {\n foo: any;\n}\n" + }, + "diagnostics": [], + "classes": { + "SomeCmp": { + "inputs": "{attr1:\"attr1\",prop1:\"prop1\",attrInterp1:\"attrInterp1\",propInterp1:\"propInterp1\"}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + }, + "MyCmp": { + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-inlineArrowNoParams", + "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": [], + "classes": { + "Dir": { + "inputs": "{v:[2,\"v\",\"v\",()=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_v": "staticngAcceptInputType_v:unknown;" + }, + "Dir2": { + "inputs": "{w:[2,\"w\",\"w\",()=>1]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_w": "staticngAcceptInputType_w:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-importedFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\n\n@Directive({selector: \"[d]\"})\nexport class Dir { @Input({transform: booleanAttribute}) v: any; }\n@Directive({selector: \"[e]\", inputs: [{name: \"w\", transform: booleanAttribute}]})\nexport class Dir2 { w: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{v:[2,\"v\",\"v\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_v": "staticngAcceptInputType_v:unknown;" + }, + "Dir2": { + "inputs": "{w:[2,\"w\",\"w\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_w": "staticngAcceptInputType_w:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: transform-thisOnly", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from \"@angular/core\";\nexport function f(this: 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": [], + "classes": { + "Dir": { + "inputs": "{v:[2,\"v\",\"v\",f]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_v": "staticngAcceptInputType_v:unknown;" + }, + "Dir2": { + "inputs": "{w:[2,\"w\",\"w\",f]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_w": "staticngAcceptInputType_w:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsObj", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", inputs: {a: 1}})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "Failed to resolve @Directive.inputs to an array Value is of type '{ a: number }'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsCall", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\ndeclare function mk(): any;\n@Directive({selector: \"[d]\", inputs: mk()})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "Failed to resolve @Directive.inputs to an array Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsRef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\", inputs: U})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "Failed to resolve @Directive.inputs to an array Value is a reference to 'U'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsElemRef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\", inputs: [U]})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is a reference to 'U'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsElemDyn", + "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]\", inputs: [X]})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is a reference to 'X'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsNameNum", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", inputs: [{name: 1}]})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "Value at position 0 of @Directive.inputs array must have a \"name\" property Value is of type 'number'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-inputsNameRef", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\nexport class U {}\n@Directive({selector: \"[d]\", inputs: [{name: U}]})\nexport class Dir { a: any; }\n" + }, + "diagnostics": [ + "Value at position 0 of @Directive.inputs array must have a \"name\" property Value is a reference to 'U'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: eval-multiErr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nimport * as core from \"@angular/core\";\n\n@Directive({selector: \"[d]\", inputs: [1, 2], outputs: [3]})\nexport class Dir { @Input({transform: null}) v: any; }\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value is of type 'number'." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: resolve-fnMultiStmt", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nfunction mk() { const x = [\"w\"]; return x; }\n@Directive({selector: \"[d]\", inputs: mk()})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [ + "Failed to resolve @Directive.inputs to an array Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: resolve-arrowConstFn", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nconst mk = () => [\"w\"];\n@Directive({selector: \"[d]\", inputs: mk()})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [ + "Failed to resolve @Directive.inputs to an array Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: resolve-letArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nlet INPUTS = [\"w\"];\n@Directive({selector: \"[d]\", inputs: INPUTS})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{w:\"w\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: resolve-varArr", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nvar INPUTS = [\"w\"];\n@Directive({selector: \"[d]\", inputs: INPUTS})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{w:\"w\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: resolve-letStrElem", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nlet S = \"w\";\n@Directive({selector: \"[d]\", inputs: [S]})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{w:\"w\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: resolve-inputsTplName", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nconst P = \"w\";\n@Directive({selector: \"[d]\", inputs: [`${P}`]})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{w:\"w\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: resolve-inputsNameViaConst", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\nconst N = \"w\";\n@Directive({selector: \"[d]\", inputs: [{name: N, alias: `x${N}`}]})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{w:[0,\"xw\",\"w\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: resolve-inputsHole", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input} from \"@angular/core\";\n\n@Directive({selector: \"[d]\", inputs: [\"w\", , \"a\"]})\nexport class Dir { w: any; a: any; }\n" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: basic-all-forms", + "origin": "probe", + "files": { + "test.ts": "import { Component, Directive, Input, Output, EventEmitter, input } from '@angular/core';\n\nexport function bool(v: unknown): boolean { return v === '' || v === true; }\n\n@Directive({\n selector: 'a',\n inputs: ['x', 'legacy: legacyAlias', { name: 'y', alias: 'why', transform: bool }, { name: 'r', required: true }, { name: 'z' }],\n outputs: ['ev', 'ev2: evAlias'],\n})\nexport class A {\n x?: string; legacy?: string; y?: boolean; r!: string; z?: string;\n ev = new EventEmitter(); ev2 = new EventEmitter();\n}\n\n@Directive({ inputs: ['baseIn', { name: 'baseT', alias: 'bt', transform: bool }], outputs: ['baseOut: bo'] })\nexport abstract class B {\n @Input() p?: string;\n @Input({ alias: 'q-q', transform: bool }) q?: boolean;\n baseIn?: string; baseT?: boolean; baseOut = new EventEmitter();\n}\n\n@Directive({ selector: 'c' })\nexport class C extends B {}\n\n@Component({\n selector: 'd',\n template: '',\n inputs: ['x', 'legacy: legacyAlias', { name: 'y', alias: 'why', transform: bool }, { name: 'r', required: true }],\n outputs: ['ev', 'ev2: evAlias'],\n})\nexport class D extends B {\n @Input('overridden') x?: string;\n sig = input();\n legacy?: string; y?: boolean; r!: string;\n ev = new EventEmitter(); ev2 = new EventEmitter();\n}\n" + }, + "diagnostics": [], + "classes": { + "A": { + "inputs": "{x:\"x\",legacy:[0,\"legacyAlias\",\"legacy\"],y:[2,\"why\",\"y\",bool],r:\"r\",z:\"z\"}", + "outputs": "{ev:\"ev\",ev2:\"evAlias\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_y": "staticngAcceptInputType_y:unknown;" + }, + "B": { + "inputs": "{baseIn:\"baseIn\",baseT:[2,\"bt\",\"baseT\",bool],p:\"p\",q:[2,\"q-q\",\"q\",bool]}", + "outputs": "{baseOut:\"bo\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_baseT": "staticngAcceptInputType_baseT:unknown;", + "dts:ngAcceptInputType_q": "staticngAcceptInputType_q:unknown;" + }, + "C": { + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + }, + "D": { + "inputs": "{x:[0,\"overridden\",\"x\"],legacy:[0,\"legacyAlias\",\"legacy\"],y:[2,\"why\",\"y\",bool],r:\"r\",sig:[1,\"sig\"]}", + "outputs": "{ev:\"ev\",ev2:\"evAlias\"}", + "dts:ɵcmp": "staticɵcmp:i0.ɵɵComponentDeclaration;", + "dts:ngAcceptInputType_y": "staticngAcceptInputType_y:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-duplicateMetadataEntries", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: ['a', 'b', 'a: c'], outputs: ['o', 'p', 'o: q']})\nexport class Dir { a: any; b: any; o: any; p: any; }" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"c\",\"a\"],b:\"b\"}", + "outputs": "{o:\"q\",p:\"p\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-extraColonSegments", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\n@Directive({selector: '[d]', inputs: ['a: b: c'], outputs: ['o: p: q']})\nexport class Dir { a: any; o: any; }" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"b\",\"a\"]}", + "outputs": "{o:\"p\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-dtsMemberOverridesImportedTransform", + "origin": "probe", + "files": { + "test.ts": "import {Directive, Input, booleanAttribute} from '@angular/core';\nexport function localFn(v: string) { return 1; }\n@Directive({selector: '[d]', inputs: [{name: 'x', transform: localFn}]})\nexport class Dir { @Input({transform: booleanAttribute}) x: any; }" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{x:[2,\"x\",\"x\",booleanAttribute]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;", + "dts:ngAcceptInputType_x": "staticngAcceptInputType_x:unknown;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-numberSmall", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`b${1e-7}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{\"b1e-7\":\"b1e-7\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-numberLarge", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`d${123456789012345680000}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{d123456789012345680000:\"d123456789012345680000\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-numberMicro", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`e${0.000001}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{\"e0.000001\":\"e0.000001\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-numberFraction", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`f${1.5}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{\"f1.5\":\"f1.5\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-infinityGlobal", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`c${Infinity}`]})\nexport class Dir {}" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: review-nanGlobal", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`n${NaN}`]})\nexport class Dir {}" + }, + "diagnostics": [ + "@Directive.inputs array can only contain strings or object literals Value could not be determined statically." + ], + "classes": {}, + "dtsImports": [] + }, + { + "name": "probe: review-undefinedKeyword", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`u${undefined}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{uundefined:\"uundefined\"}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + }, + { + "name": "probe: review-numberExponent", + "origin": "probe", + "files": { + "test.ts": "import {Directive} from \"@angular/core\";\n@Directive({selector: \"[d]\", inputs: [`a: x${1e21}`, `b: y${2.5e300}`]})\nexport class Dir {}" + }, + "diagnostics": [], + "classes": { + "Dir": { + "inputs": "{a:[0,\"x1e+21\",\"a\"],b:[0,\"y2.5e+300\",\"b\"]}", + "dts:ɵdir": "staticɵdir:i0.ɵɵDirectiveDeclaration;" + } + }, + "dtsImports": [] + } + ] +} diff --git a/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/decorator-metadata-angular-spec.fixture.ts b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/decorator-metadata-angular-spec.fixture.ts new file mode 100644 index 000000000..95950f8ff --- /dev/null +++ b/napi/angular-compiler/e2e/compare/fixtures/inputs-outputs/decorator-metadata-angular-spec.fixture.ts @@ -0,0 +1,248 @@ +/** + * `inputs:` / `outputs:` in decorator metadata, ported from Angular's own tests + * (angular/angular v22.1.7). Acceptance-spec sources are made standalone + * (`imports: [Dir]`) in place of their TestBed NgModule, and drop the TestBed-only + * `changeDetection: Eager`; directive metadata is verbatim. + */ +import type { Fixture } from '../types.js' + +const fixture = ( + name: string, + description: string, + className: string, + sourceCode: string, +): Fixture => ({ + type: 'full-transform', + name: `decorator-metadata-${name}`, + category: 'inputs-outputs', + description, + className, + sourceCode: sourceCode.trim(), +}) + +export const fixtures: Fixture[] = [ + // compiler-cli/test/ngtsc/ngtsc_spec.ts: 'should allow directives with no selector that are not in NgModules' + fixture( + 'no-selector', + 'selectorless directive with an inputs array', + 'TestDirWithInputs', + ` +import {Directive} from '@angular/core'; + +@Directive({}) +export class BaseDir {} + +@Directive({}) +export abstract class AbstractBaseDir {} + +@Directive() +export abstract class EmptyDir {} + +@Directive({ + inputs: ['a', 'b'], + standalone: false, +}) +export class TestDirWithInputs {} +`, + ), + // ngtsc_spec.ts: 'should wrap "inputs" and "outputs" keys if they contain unsafe characters' + fixture( + 'unsafe-keys', + 'inputs/outputs names that need quoting, alongside @Input members', + 'SomeDir', + ` +import {Directive, Input} from '@angular/core'; + +@Directive({ + selector: '[somedir]', + inputs: ['track-type', 'track-name', 'inputTrackName', 'src.xl'], + outputs: ['output-track-type', 'output-track-name', 'outputTrackName', 'output.event'] +}) +export class SomeDir { + @Input('track-type') trackType: string; + @Input('track-name') trackName: string; +} +`, + ), + // ngtsc_spec.ts: 'should generate the correct declaration for directives using the `inputs` array' + fixture( + 'all-forms', + 'every string and object form of the inputs array', + 'TestDir', + ` +import {Directive, Input} from '@angular/core'; + +@Directive({ + selector: '[dir]', + inputs: [ + 'plain', + 'withAlias: aliasedWithAlias', + {name: 'plainLiteral'}, + {name: 'aliasedLiteral', alias: 'alisedLiteralAlias'}, + {name: 'requiredLiteral', required: true}, + {name: 'requiredAlisedLiteral', alias: 'requiredAlisedLiteralAlias', required: true} + ], + standalone: false, +}) +export class TestDir { + plainLiteral: any; + aliasedLiteral: any; + requiredLiteral: any; + requiredAlisedLiteral: any; +} +`, + ), + // ngtsc_spec.ts: 'should *not* generate a validator fn for attribute and property bindings when *not* on