diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 2f6184ae0..a707fc36c 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -2881,12 +2881,18 @@ pub fn transform_angular_file( .type_parameters .as_ref() .map_or(0, |tp| tp.params.len() as u32); + let accept_types = crate::directive::input_transform_types( + class, + &string_consts, + source, + ); result.dts_declarations.push(dts::generate_component_dts( &metadata, type_argument_count, &content_query_names, has_injectable, &compilation_result.ng_content_selectors, + &accept_types, )); result.component_count += 1; @@ -3003,9 +3009,13 @@ pub fn transform_angular_file( let type_argument_count = class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32); directive_metadata.type_argument_count = type_argument_count; - result - .dts_declarations - .push(dts::generate_directive_dts(&directive_metadata, has_injectable)); + let accept_types = + crate::directive::input_transform_types(class, &string_consts, source); + result.dts_declarations.push(dts::generate_directive_dts( + &directive_metadata, + has_injectable, + &accept_types, + )); // Emit setClassMetadata for TestBed support (overrideDirective + // signal members), mirroring the @Component path. diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs new file mode 100644 index 000000000..0c016bb06 --- /dev/null +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -0,0 +1,815 @@ +//! Prints a TypeScript type the way ngtsc writes it into a `.d.ts`: through +//! its `TypeEmitter` (every reference to an `@angular/core` name becomes +//! `i0.Name`, literals are re-created) and TypeScript's printer (normalised +//! spacing, double-quoted ASCII-only strings, only some comments kept). +//! +//! Used for `static ngAcceptInputType_x: T;`, where `T` is the type of the +//! input transform's first parameter. +//! +//! Every part of the type is printed from the AST, never copied from the +//! source, so no position can keep a name ngtsc would have rewritten. A form +//! this printer doesn't cover makes the whole type `unknown`. + +use oxc_ast::ast::{ + BigIntLiteral, BindingPattern, Expression, FormalParameters, PropertyKey, StringLiteral, + TSLiteral, TSMappedTypeModifierOperator, TSMethodSignatureKind, TSSignature, TSThisParameter, + TSTupleElement, TSType, TSTypeAnnotation, TSTypeName, TSTypeOperatorOperator, TSTypeParameter, + TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypePredicateName, + TSTypeQueryExprName, UnaryOperator, +}; +use oxc_span::GetSpan; + +use super::evaluator::{AliasTarget, FileScope}; +use crate::output::emitter::format_number_like_js; + +pub(crate) struct TypePrinter<'s, 'a> { + pub scope: &'s FileScope<'a>, + pub source: &'a str, + /// Set when the type references a module other than `@angular/core`. + /// ngtsc would add an `import * as iN` for it; oxc emits `unknown` instead, + /// since aliases numbered per source file can't be merged into bundled + /// declaration files safely. + pub other_module: bool, +} + +impl<'a> TypePrinter<'_, 'a> { + /// `unknown` when the type has a form ngtsc can't emit either (an + /// `import('...')` type) or that isn't valid in a type annotation. + pub(crate) fn print(&mut self, ty: &TSType<'_>) -> String { + self.ty(ty).unwrap_or_else(|| "unknown".to_string()) + } + + fn ty(&mut self, ty: &TSType<'_>) -> Option { + Some(match ty { + TSType::TSAnyKeyword(_) => "any".into(), + TSType::TSBigIntKeyword(_) => "bigint".into(), + TSType::TSBooleanKeyword(_) => "boolean".into(), + TSType::TSIntrinsicKeyword(_) => "intrinsic".into(), + TSType::TSNeverKeyword(_) => "never".into(), + TSType::TSNullKeyword(_) => "null".into(), + TSType::TSNumberKeyword(_) => "number".into(), + TSType::TSObjectKeyword(_) => "object".into(), + TSType::TSStringKeyword(_) => "string".into(), + TSType::TSSymbolKeyword(_) => "symbol".into(), + TSType::TSUndefinedKeyword(_) => "undefined".into(), + TSType::TSUnknownKeyword(_) => "unknown".into(), + TSType::TSVoidKeyword(_) => "void".into(), + TSType::TSThisType(_) => "this".into(), + TSType::TSTypeReference(r) => { + let mut out = self.type_name(&r.type_name)?; + if let Some(args) = &r.type_arguments { + out.push_str(&self.type_args(args)?); + } + out + } + TSType::TSUnionType(u) => self.constituents(&u.types, u.span.start, "|")?, + TSType::TSIntersectionType(i) => self.constituents(&i.types, i.span.start, "&")?, + TSType::TSParenthesizedType(p) => format!("({})", self.ty(&p.type_annotation)?), + TSType::TSArrayType(a) => format!("{}[]", self.ty(&a.element_type)?), + TSType::TSIndexedAccessType(i) => { + format!("{}[{}]", self.ty(&i.object_type)?, self.ty(&i.index_type)?) + } + TSType::TSTypeOperatorType(o) => { + let op = match o.operator { + TSTypeOperatorOperator::Keyof => "keyof", + TSTypeOperatorOperator::Unique => "unique", + TSTypeOperatorOperator::Readonly => "readonly", + }; + format!("{op} {}", self.ty(&o.type_annotation)?) + } + TSType::TSTupleType(t) => { + let items = + self.elements(Some(t.span.start), &t.element_types, Self::tuple_element)?; + format!("[{}]", items.join(", ")) + } + TSType::TSNamedTupleMember(m) => format!( + "{}{}: {}", + m.label.name, + if m.optional { "?" } else { "" }, + self.tuple_element(&m.element_type)? + ), + TSType::TSLiteralType(l) => self.literal(&l.literal)?, + TSType::TSTemplateLiteralType(t) => { + let mut out = String::from("`"); + for (i, quasi) in t.quasis.iter().enumerate() { + out.push_str(quasi.value.raw.as_str()); + if let Some(ty) = t.types.get(i) { + out.push_str("${"); + out.push_str(&self.ty(ty)?); + out.push('}'); + } + } + out.push('`'); + out + } + TSType::TSTypeQuery(q) => { + // `typeof x` names a value; ngtsc leaves it as written. + let mut out = match &q.expr_name { + TSTypeQueryExprName::IdentifierReference(id) => format!("typeof {}", id.name), + TSTypeQueryExprName::QualifiedName(name) => { + format!("typeof {}.{}", entity_name(&name.left)?, name.right.name) + } + TSTypeQueryExprName::ThisExpression(_) => "typeof this".into(), + TSTypeQueryExprName::TSImportType(_) => return None, + }; + if let Some(args) = &q.type_arguments { + out.push_str(&self.type_args(args)?); + } + out + } + TSType::TSTypeLiteral(l) if l.members.is_empty() => "{}".into(), + TSType::TSTypeLiteral(l) => { + let members = self.elements(Some(l.span.start), &l.members, Self::member)?; + format!("{{ {} }}", members.join(" ")) + } + TSType::TSMappedType(m) => { + let readonly = match m.readonly { + None => "", + Some(TSMappedTypeModifierOperator::True) => "readonly ", + Some(TSMappedTypeModifierOperator::Plus) => "+readonly ", + Some(TSMappedTypeModifierOperator::Minus) => "-readonly ", + }; + let optional = match m.optional { + None => "", + Some(TSMappedTypeModifierOperator::True) => "?", + Some(TSMappedTypeModifierOperator::Plus) => "+?", + Some(TSMappedTypeModifierOperator::Minus) => "-?", + }; + let name_type = match &m.name_type { + Some(t) => format!(" as {}", self.ty(t)?), + None => String::new(), + }; + let value = match &m.type_annotation { + Some(t) => self.ty(t)?, + None => String::new(), + }; + format!( + "{{ {readonly}[{} in {}{name_type}]{optional}: {value}; }}", + m.key.name, + self.ty(&m.constraint)? + ) + } + TSType::TSFunctionType(f) => format!( + "{}{} => {}", + self.type_params(f.type_parameters.as_deref())?, + self.params(f.this_param.as_deref(), &f.params)?, + self.ty(&f.return_type.type_annotation)? + ), + TSType::TSConstructorType(c) => format!( + "{}new {}{} => {}", + if c.r#abstract { "abstract " } else { "" }, + self.type_params(c.type_parameters.as_deref())?, + self.params(None, &c.params)?, + self.ty(&c.return_type.type_annotation)? + ), + TSType::TSConditionalType(c) => format!( + "{} extends {} ? {} : {}", + self.ty(&c.check_type)?, + self.ty(&c.extends_type)?, + self.ty(&c.true_type)?, + self.ty(&c.false_type)? + ), + TSType::TSInferType(i) => format!("infer {}", self.type_param(&i.type_parameter)?), + TSType::TSTypePredicate(p) => { + let name = match &p.parameter_name { + TSTypePredicateName::Identifier(id) => id.name.as_str(), + TSTypePredicateName::This(_) => "this", + }; + let mut out = if p.asserts { format!("asserts {name}") } else { name.to_string() }; + if let Some(t) = &p.type_annotation { + out.push_str(" is "); + out.push_str(&self.ty(&t.type_annotation)?); + } + out + } + // TypeScript reports these as errors but prints them, with the + // `?`/`!` in front even when it was written after the type. + TSType::JSDocNullableType(t) => format!("?{}", self.ty(&t.type_annotation)?), + TSType::JSDocNonNullableType(t) => format!("!{}", self.ty(&t.type_annotation)?), + TSType::JSDocUnknownType(_) => "?".into(), + // ngtsc throws "Unable to emit import type" on `import('...')`. + TSType::TSImportType(_) => return None, + }) + } + + /// `@angular/core` names become `i0.Name`; local and global names stay as + /// written; names from other modules set `other_module`. + /// + /// A qualified name whose head the file declares (`NS.T`, `C.T` for a + /// class merged with a namespace, `A.B.T`, `E.T` for an enum merged with + /// one) is written as its last part, `T`: ngtsc emits the declaration `T` + /// resolves to by its own name. An enum member (`E.A`, `NS.E.A`) stays as + /// written, since ngtsc can't emit one at all. + /// + /// An import-equals alias (`import A = NS`, `import C = NS.T`, or one + /// declared in a namespace, `NS.A`) stands for its target, as ngtsc + /// resolves it: `A.T`, `C` and `NS.A` are written as the name of the + /// declaration they resolve to. An alias of another module + /// (`import R = require('m')`, or of an import) is that module's, and one + /// of `@angular/core` gives `i0.X`; ngtsc writes the bare name there, + /// which doesn't resolve. + fn type_name(&mut self, name: &TSTypeName<'_>) -> Option { + let mut parts = std::vec::Vec::new(); + entity_parts(name, &mut parts)?; + Some(match self.resolve_aliases(parts)? { + Resolved::Core(rest) => format!("i0{rest}"), + Resolved::OtherModule(parts) => { + self.other_module = true; + parts.join(".") + } + Resolved::Name(parts, _) => self.resolved_name(&parts), + }) + } + + /// `parts` (a name, head first) with the import-equals aliases it starts + /// with replaced by their targets, until its head isn't one. + fn resolve_aliases<'p>(&self, mut parts: std::vec::Vec<&'p str>) -> Option> + where + 'a: 'p, + { + let mut aliased = false; + // Aliases can name aliases, through any number of them. Meeting one + // again is a cycle (`import A = B; import B = A;`, `import A = A.B;`), + // which TypeScript reports. + let mut seen: std::vec::Vec> = std::vec::Vec::new(); + loop { + let (len, target) = match self.scope.alias(parts[0]) { + Some(target) => (1, target), + None => match self.scope.namespace_alias(&parts) { + Some(found) => found, + None => return Some(Resolved::Name(parts, aliased)), + }, + }; + if seen.iter().any(|alias| alias[..] == parts[..len]) { + return None; + } + seen.push(parts[..len].to_vec()); + aliased = true; + match target { + // `import Core = require('@angular/core')`: `Core.X` is + // `i0.X`, like a namespace import's member. + AliasTarget::Module("@angular/core") => { + return Some(Resolved::Core( + parts[len..].iter().map(|m| format!(".{m}")).collect(), + )); + } + AliasTarget::Module(_) => return Some(Resolved::OtherModule(parts)), + AliasTarget::Entity(target) => { + parts.splice(0..len, target); + } + } + } + } + + /// [`Self::type_name`] for a name whose head isn't an alias: `parts`, head + /// first. + fn resolved_name(&mut self, parts: &[&str]) -> String { + let (head, members) = (parts[0], &parts[1..]); + if let [qualifier @ .., last] = parts + && !members.is_empty() + && self.scope.import(head).is_none() + && self.scope.declares(head) + && !self.scope.is_enum_member(qualifier, last) + { + return (*last).to_string(); + } + let rest: String = members.iter().map(|m| format!(".{m}")).collect(); + self.value_name(head, &rest) + } + + /// `head` followed by the `.member`s `rest`, with `head` resolved through + /// the file's imports: `i0` for `@angular/core`, `other_module` for any + /// other module, as written otherwise. + fn value_name(&mut self, head: &str, rest: &str) -> String { + match self.scope.import(head) { + Some(import) if import.module == "@angular/core" => match import.imported { + Some(imported) => format!("i0.{imported}{rest}"), + None => format!("i0{rest}"), + }, + Some(_) => { + self.other_module = true; + format!("{head}{rest}") + } + None => format!("{head}{rest}"), + } + } + + /// A union's or intersection's constituents, joined by ` | ` / ` & `. + fn constituents(&mut self, types: &[TSType<'_>], start: u32, op: &str) -> Option { + // After a leading `|`/`&`, the first constituent follows that token; + // otherwise it shares the start of the union (see `leading_comments`). + let open = self.source[start as usize..].starts_with(op).then_some(start); + Some(self.elements(open, types, Self::ty)?.join(&format!(" {op} "))) + } + + fn type_args(&mut self, args: &TSTypeParameterInstantiation<'_>) -> Option { + Some(format!( + "<{}>", + self.elements(Some(args.span.start), &args.params, Self::ty)?.join(", ") + )) + } + + fn type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) -> Option { + let Some(params) = params else { return Some(String::new()) }; + let items = self.elements(Some(params.span.start), ¶ms.params, Self::type_param)?; + Some(format!("<{}>", items.join(", "))) + } + + fn type_param(&mut self, param: &TSTypeParameter<'_>) -> Option { + let mut out = String::new(); + if param.r#const { + out.push_str("const "); + } + if param.r#in { + out.push_str("in "); + } + if param.out { + out.push_str("out "); + } + out.push_str(¶m.name.name); + if let Some(constraint) = ¶m.constraint { + out.push_str(" extends "); + out.push_str(&self.ty(constraint)?); + } + if let Some(default) = ¶m.default { + out.push_str(" = "); + out.push_str(&self.ty(default)?); + } + Some(out) + } + + fn tuple_element(&mut self, element: &TSTupleElement<'_>) -> Option { + match element { + TSTupleElement::TSOptionalType(o) => Some(format!("{}?", self.ty(&o.type_annotation)?)), + TSTupleElement::TSRestType(r) => Some(format!("...{}", self.ty(&r.type_annotation)?)), + other => self.ty(other.to_ts_type()), + } + } + + fn literal(&self, literal: &TSLiteral<'_>) -> Option { + Some(match literal { + TSLiteral::BooleanLiteral(b) => b.value.to_string(), + TSLiteral::NumericLiteral(n) => format_number_like_js(n.value), + TSLiteral::BigIntLiteral(b) => self.bigint(b), + TSLiteral::StringLiteral(s) => quote_literal(s), + TSLiteral::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), + TSLiteral::UnaryExpression(u) if u.operator == UnaryOperator::UnaryNegation => { + match &u.argument { + Expression::NumericLiteral(n) => format!("-{}", format_number_like_js(n.value)), + Expression::BigIntLiteral(b) => format!("-{}", self.bigint(b)), + _ => return None, + } + } + _ => return None, + }) + } + + /// TypeScript's scanner keeps hexadecimal bigints in hex (lowercased) and + /// turns the other bases into decimal, without separators. + fn bigint(&self, literal: &BigIntLiteral<'_>) -> String { + let raw = self.slice(literal.span); + match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + Some(hex) => format!("0x{}", hex.replace('_', "").to_ascii_lowercase()), + None => format!("{}n", literal.value), + } + } + + fn member(&mut self, member: &TSSignature<'_>) -> Option { + Some(match member { + TSSignature::TSPropertySignature(p) => format!( + "{}{}{}{};", + if p.readonly { "readonly " } else { "" }, + self.key(&p.key, p.computed)?, + if p.optional { "?" } else { "" }, + self.annotation(p.type_annotation.as_deref())? + ), + TSSignature::TSIndexSignature(s) => { + let param = &s.parameter; + format!( + "{}[{}{}: {}]: {};", + if s.readonly { "readonly " } else { "" }, + self.leading_comments(None, param.span.start), + param.name, + self.ty(¶m.type_annotation.type_annotation)?, + self.ty(&s.type_annotation.type_annotation)? + ) + } + TSSignature::TSCallSignatureDeclaration(c) => format!( + "{}{}{};", + self.type_params(c.type_parameters.as_deref())?, + self.params(c.this_param.as_deref(), &c.params)?, + self.annotation(c.return_type.as_deref())? + ), + TSSignature::TSConstructSignatureDeclaration(c) => format!( + "new {}{}{};", + self.type_params(c.type_parameters.as_deref())?, + self.params(None, &c.params)?, + self.annotation(c.return_type.as_deref())? + ), + TSSignature::TSMethodSignature(m) => format!( + "{}{}{}{}{}{};", + match m.kind { + TSMethodSignatureKind::Method => "", + TSMethodSignatureKind::Get => "get ", + TSMethodSignatureKind::Set => "set ", + }, + self.key(&m.key, m.computed)?, + if m.optional { "?" } else { "" }, + self.type_params(m.type_parameters.as_deref())?, + self.params(m.this_param.as_deref(), &m.params)?, + self.annotation(m.return_type.as_deref())? + ), + }) + } + + /// `: T`, or nothing when there's no annotation. + fn annotation(&mut self, annotation: Option<&TSTypeAnnotation<'_>>) -> Option { + match annotation { + Some(t) => Some(format!(": {}", self.ty(&t.type_annotation)?)), + None => Some(String::new()), + } + } + + /// A property name, re-quoted like any other string literal. A computed + /// name (`[token]`, `[ns.token]`) follows the rules of a type name: an + /// `@angular/core` value becomes `i0.token`, another module's makes the + /// type `unknown`, and a local or global one stays as written (ngtsc copies + /// the expression, which doesn't resolve for an import). + /// + /// Through an import-equals alias too, although ngtsc copies those as + /// written: an alias of `@angular/core` gives `i0.token` and one of another + /// module `unknown`, where ngtsc's name doesn't resolve in the `.d.ts`. An + /// alias of a global is written as its target (`[Symbol.iterator]` for + /// `import S = Symbol`), and one of a name the file declares stays as + /// written, like ngtsc. + fn key(&mut self, key: &PropertyKey<'_>, computed: bool) -> Option { + let text = match key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => quote_literal(s), + PropertyKey::NumericLiteral(n) => format_number_like_js(n.value), + PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), + PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?, + PropertyKey::StaticMemberExpression(m) => { + let mut parts = std::vec::Vec::new(); + member_parts(&m.object, &mut parts)?; + parts.push(m.property.name.as_str()); + self.computed_name(parts)? + } + _ => return None, + }; + Some(if computed { format!("[{text}]") } else { text }) + } + + /// A computed property name `a.b.c` (`parts`, head first), as [`Self::key`] + /// writes it. + fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option { + let written = parts.join("."); + Some(match self.resolve_aliases(parts)? { + Resolved::Core(rest) => format!("i0{rest}"), + Resolved::OtherModule(_) => { + self.other_module = true; + written + } + Resolved::Name(parts, aliased) => { + let (head, members) = (parts[0], &parts[1..]); + if aliased && self.scope.import(head).is_none() && self.scope.declares(head) { + written + } else { + let rest: String = members.iter().map(|m| format!(".{m}")).collect(); + self.value_name(head, &rest) + } + } + }) + } + + fn params( + &mut self, + this: Option<&TSThisParameter<'_>>, + params: &FormalParameters<'_>, + ) -> Option { + let mut out = Vec::new(); + let mut prev = params.span.start; + if let Some(this) = this { + let comments = self.leading_comments(Some(prev), this.span.start); + out.push(format!( + "{comments}this{}", + self.annotation(this.type_annotation.as_deref())? + )); + prev = this.span.end; + } + for param in ¶ms.items { + if param.initializer.is_some() { + return None; + } + let comments = self.leading_comments(Some(prev), param.span.start); + out.push(format!( + "{comments}{}{}{}", + self.binding(¶m.pattern)?, + if param.optional { "?" } else { "" }, + self.annotation(param.type_annotation.as_deref())? + )); + prev = param.span.end; + } + if let Some(rest) = ¶ms.rest { + let comments = self.leading_comments(Some(prev), rest.span.start); + out.push(format!( + "{comments}...{}{}", + self.binding(&rest.rest.argument)?, + self.annotation(rest.type_annotation.as_deref())? + )); + } + Some(format!("({})", out.join(", "))) + } + + /// A parameter name or destructuring pattern. + fn binding(&mut self, pattern: &BindingPattern<'_>) -> Option { + match pattern { + BindingPattern::BindingIdentifier(id) => Some(id.name.to_string()), + BindingPattern::ObjectPattern(o) if o.properties.is_empty() && o.rest.is_none() => { + Some("{}".into()) + } + BindingPattern::ObjectPattern(o) => { + let mut items = Vec::new(); + let mut prev = o.span.start; + for property in &o.properties { + let value = self.binding(&property.value)?; + let item = if property.shorthand { + value + } else { + format!("{}: {value}", self.key(&property.key, property.computed)?) + }; + items.push(self.leading_comments(Some(prev), property.span.start) + &item); + prev = property.span.end; + } + if let Some(rest) = &o.rest { + let comments = self.leading_comments(Some(prev), rest.span.start); + items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); + } + Some(format!("{{ {} }}", items.join(", "))) + } + BindingPattern::ArrayPattern(a) => { + let mut items = Vec::new(); + let mut prev = a.span.start; + for element in &a.elements { + let Some(element) = element else { + items.push(String::new()); + continue; + }; + let comments = self.leading_comments(Some(prev), element.span().start); + items.push(comments + &self.binding(element)?); + prev = element.span().end; + } + if let Some(rest) = &a.rest { + let comments = self.leading_comments(Some(prev), rest.span.start); + items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); + } + Some(format!("[{}]", items.join(", "))) + } + // Default values aren't allowed in a type. + BindingPattern::AssignmentPattern(_) => None, + } + } + + /// Prints each element of a list, with the comments TypeScript keeps in + /// front of it. `open` is where the list's opening token (`<`, `(`, `{`, + /// `[`, a leading `|`) starts; `None` when the list has none, so the token + /// before the first element is looked for backwards. + fn elements( + &mut self, + open: Option, + items: &[T], + mut print: impl FnMut(&mut Self, &T) -> Option, + ) -> Option> { + let mut prev = open; + items + .iter() + .map(|item| { + let span = item.span(); + let comments = self.leading_comments(prev, span.start); + prev = Some(span.end); + Some(comments + &print(self, item)?) + }) + .collect() + } + + /// The comments TypeScript's printer keeps in front of a list element: + /// those right after the token before it, on that token's line (it emits + /// them as trailing comments of that position). Other comments in the type + /// are dropped, as ngtsc does, except `/** */` ones after a type or on + /// their own line, which ngtsc keeps and oxc doesn't. + /// + /// `prev` is where to look for that token from: the end of the previous + /// element (the token is the delimiter, if any) or the opening bracket. + /// Without it the token is searched for backwards from `start`, which is + /// how a union's first constituent finds the token before the union. + fn leading_comments(&self, prev: Option, start: u32) -> String { + let start = start as usize; + let pos = match prev { + Some(prev) => after_token(self.source, prev as usize, start), + None => match token_end_before(self.source, start) { + Some(pos) => pos, + None => return String::new(), + }, + }; + same_line_comments(&self.source[pos..start]) + } + + fn slice(&self, span: oxc_span::Span) -> String { + span.source_text(self.source).to_string() + } +} + +/// A `typeof` operand or computed key: `a.b.c`, as ngtsc prints it (without +/// the source's spacing). +fn entity_name(name: &TSTypeName<'_>) -> Option { + match name { + TSTypeName::IdentifierReference(id) => Some(id.name.to_string()), + TSTypeName::QualifiedName(q) => Some(format!("{}.{}", entity_name(&q.left)?, q.right.name)), + TSTypeName::ThisExpression(_) => Some("this".into()), + } +} + +/// A type name's parts, head first (`["A", "B", "T"]` for `A.B.T`). `None` +/// for one starting with `this`. +fn entity_parts<'n>(name: &'n TSTypeName<'_>, out: &mut Vec<&'n str>) -> Option<()> { + match name { + TSTypeName::IdentifierReference(id) => out.push(id.name.as_str()), + TSTypeName::QualifiedName(q) => { + entity_parts(&q.left, out)?; + out.push(q.right.name.as_str()); + } + TSTypeName::ThisExpression(_) => return None, + } + Some(()) +} + +/// The parts of a computed name's object, `a.b` in `[a.b.c]`, head first. +fn member_parts<'n>(object: &'n Expression<'_>, out: &mut Vec<&'n str>) -> Option<()> { + match object { + Expression::Identifier(id) => out.push(id.name.as_str()), + Expression::StaticMemberExpression(m) if !m.optional => { + member_parts(&m.object, out)?; + out.push(m.property.name.as_str()); + } + _ => return None, + } + Some(()) +} + +/// A name with its import-equals aliases replaced by their targets. +enum Resolved<'p> { + /// An alias of `@angular/core`: the `.member`s after it. + Core(String), + /// An alias of another module: the name as written. + OtherModule(std::vec::Vec<&'p str>), + /// Any other name, head first, and whether an alias was replaced. + Name(std::vec::Vec<&'p str>, bool), +} + +fn is_line_break(c: char) -> bool { + matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') +} + +fn is_space(c: char) -> bool { + c.is_whitespace() && !is_line_break(c) +} + +/// The end of the first token at or after `from`, when one comes before +/// `start`; `from` itself otherwise. +fn after_token(source: &str, from: usize, start: usize) -> usize { + let text = &source[from..start]; + let mut rest = text; + loop { + rest = rest.trim_start(); + if let Some(body) = rest.strip_prefix("/*") { + match body.find("*/") { + Some(end) => rest = &body[end + 2..], + None => return from, + } + } else if rest.starts_with("//") { + match rest.find(is_line_break) { + Some(end) => rest = &rest[end..], + None => return from, + } + } else { + return match rest.chars().next() { + Some(c) => from + (text.len() - rest.len()) + c.len_utf8(), + None => from, + }; + } + } +} + +/// Where the token before `start` ends, skipping whitespace and comments +/// backwards. `None` when that crosses a line with `//` on it: a line comment +/// can't be told apart from code when reading backwards. +fn token_end_before(source: &str, start: usize) -> Option { + let mut end = start; + loop { + let before = &source[..end]; + let trimmed = before.trim_end(); + if before[trimmed.len()..].contains(is_line_break) { + // After the line break, which can be more than one byte (U+2028). + let line_start = trimmed + .char_indices() + .rfind(|&(_, c)| is_line_break(c)) + .map_or(0, |(i, c)| i + c.len_utf8()); + if trimmed[line_start..].contains("//") { + return None; + } + } + end = trimmed.len(); + match trimmed.strip_suffix("*/") { + Some(inner) => end = inner.rfind("/*")?, + None => return Some(end), + } + } +} + +/// The comments at the start of `text`, up to the first line break, as +/// TypeScript prints them there: `/* */` followed by a space, `//` by a new +/// line (indented like the `.d.ts` class members). Comments spanning lines, +/// which TypeScript re-indents, are dropped with the rest. +fn same_line_comments(text: &str) -> String { + let mut out = String::new(); + let mut rest = text; + loop { + rest = rest.trim_start_matches(is_space); + if let Some(body) = rest.strip_prefix("/*") { + let Some(end) = body.find("*/") else { break }; + let comment = &rest[..end + 4]; + if comment.contains(is_line_break) { + return String::new(); + } + out.push_str(comment); + out.push(' '); + rest = &rest[comment.len()..]; + } else { + if rest.starts_with("//") { + let end = rest.find(is_line_break).unwrap_or(rest.len()); + out.push_str(&rest[..end]); + out.push_str("\n "); + } + break; + } + } + out +} + +/// A string literal as TypeScript prints a synthesized one: double quotes, +/// escapes for control characters and everything outside ASCII (as UTF-16 +/// code units). +pub(crate) fn quote(s: &str) -> String { + quote_units(s.encode_utf16()) +} + +/// [`quote`] for a string literal from the source, whose value can hold lone +/// surrogates (`'\uD800'`): the parser encodes each as `\u{FFFD}` and its +/// code unit in hex (and a `\u{FFFD}` as `\u{FFFD}fffd`). +fn quote_literal(literal: &StringLiteral<'_>) -> String { + if !literal.lone_surrogates { + return quote(&literal.value); + } + let mut units = Vec::new(); + let mut chars = literal.value.chars(); + while let Some(c) = chars.next() { + if c == '\u{FFFD}' { + let hex: String = chars.by_ref().take(4).collect(); + if let Ok(unit) = u16::from_str_radix(&hex, 16) { + units.push(unit); + continue; + } + } + units.extend(c.encode_utf16(&mut [0; 2]).iter()); + } + quote_units(units) +} + +fn quote_units(units: impl IntoIterator) -> String { + let units: Vec = units.into_iter().collect(); + let mut out = String::with_capacity(units.len() + 2); + out.push('"'); + for (i, &unit) in units.iter().enumerate() { + match unit { + 0x22 => out.push_str("\\\""), + 0x5c => out.push_str("\\\\"), + 0x0a => out.push_str("\\n"), + 0x0d => out.push_str("\\r"), + 0x09 => out.push_str("\\t"), + 0x0b => out.push_str("\\v"), + 0x0c => out.push_str("\\f"), + 0x08 => out.push_str("\\b"), + // `\0` followed by a digit would read as an octal escape. + 0 if units.get(i + 1).is_some_and(|u| (0x30..=0x39).contains(u)) => { + out.push_str("\\x00"); + } + 0 => out.push_str("\\0"), + unit if unit < 0x20 || unit > 0x7f => { + let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{unit:04X}")); + } + unit => out.push(char::from(unit as u8)), + } + } + out.push('"'); + out +} diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index a4cae41a0..7f95e8a6d 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -59,6 +59,14 @@ pub(crate) struct FileScope<'a> { /// 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>, + /// Namespaces (`namespace NS {}`, `declare namespace NS {}`, `module NS {}`), + /// with every declaration of each (TypeScript merges them). + namespaces: HashMap<&'a str, std::vec::Vec<&'a TSNamespaceDeclaration<'a>>>, + /// Every declaration of each enum (TypeScript merges them). + enum_declarations: HashMap<&'a str, std::vec::Vec<&'a TSEnumDeclaration<'a>>>, + /// 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>>, /// 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. @@ -66,9 +74,6 @@ pub(crate) struct FileScope<'a> { /// 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>>, /// The bodies of the file's namespaces (`namespace NS { ... }`, nested /// ones included), which ngtsc reads through `typeof NS.X`. blocks: std::vec::Vec>, @@ -121,6 +126,160 @@ enum Qualified<'a> { Missing, } +/// Whether an enum `e` declares the member `member`, however its name is +/// written (`A`, `'A'`, `['A']` or `` [`A`] ``). +fn enum_declares_member(e: &TSEnumDeclaration<'_>, member: &str) -> bool { + e.body.members.iter().any(|m| match &m.id { + TSEnumMemberName::Identifier(id) => id.name == member, + TSEnumMemberName::String(s) | TSEnumMemberName::ComputedString(s) => s.value == member, + TSEnumMemberName::ComputedTemplateString(t) => { + t.single_quasi().is_some_and(|q| q == member) + } + }) +} + +/// Whether the namespace `ns` declares the enum `path` (`["M", "E"]` for +/// `M.E`, with `M` a namespace in `ns`) with the member `member`. +fn namespace_declares_enum_member( + ns: &TSNamespaceDeclaration<'_>, + path: &[&str], + member: &str, +) -> bool { + let statements = match &ns.body { + // `namespace A.B {}`: `B` is the only member of `A`. + TSNamespaceDeclarationBody::TSNamespaceDeclaration(inner) => { + return match path { + [name, rest @ ..] if !rest.is_empty() && inner.id.name == *name => { + namespace_declares_enum_member(inner, rest, member) + } + _ => false, + }; + } + TSNamespaceDeclarationBody::TSModuleBlock(block) => &block.body, + }; + statements.iter().any(|statement| { + let decl = match statement { + Statement::ExportDeclaration(export) => Some(&export.declaration), + _ => statement.as_declaration(), + }; + match (decl, path) { + (Some(Declaration::TSEnumDeclaration(e)), [name]) => { + e.id.name == *name && enum_declares_member(e, member) + } + (Some(Declaration::TSNamespaceDeclaration(inner)), [name, rest @ ..]) + if !rest.is_empty() && inner.id.name == *name => + { + namespace_declares_enum_member(inner.as_ref(), rest, member) + } + _ => false, + } + }) +} + +/// What an import-equals alias stands for (see [`FileScope::alias`]). +pub(crate) enum AliasTarget<'a> { + /// `import A = require('m')`: the module `m`. + Module(&'a str), + /// `import A = NS.T`: the entity's parts, head first (`["NS", "T"]`). + Entity(std::vec::Vec<&'a str>), +} + +impl<'a> AliasTarget<'a> { + fn of(reference: &'a TSModuleReference<'a>) -> Option { + fn parts<'a>(name: &'a TSTypeName<'a>, out: &mut std::vec::Vec<&'a str>) -> Option<()> { + match name { + TSTypeName::IdentifierReference(id) => out.push(id.name.as_str()), + TSTypeName::QualifiedName(q) => { + parts(&q.left, out)?; + out.push(q.right.name.as_str()); + } + TSTypeName::ThisExpression(_) => return None, + } + Some(()) + } + Some(match reference { + TSModuleReference::ExternalModuleReference(m) => { + AliasTarget::Module(m.expression.value.as_str()) + } + TSModuleReference::IdentifierReference(id) => { + AliasTarget::Entity(std::vec![id.name.as_str()]) + } + TSModuleReference::QualifiedName(q) => { + let mut out = std::vec::Vec::new(); + parts(&q.left, &mut out)?; + out.push(q.right.name.as_str()); + AliasTarget::Entity(out) + } + }) + } +} + +/// A declaration directly in a namespace, as far as names resolve through it. +enum NamespaceMember<'a> { + Namespace(&'a TSNamespaceDeclaration<'a>), + Alias(&'a TSModuleReference<'a>), + Other, +} + +/// The declarations directly in the namespace `ns`, by name. +fn namespace_members<'a>( + ns: &'a TSNamespaceDeclaration<'a>, +) -> std::vec::Vec<(&'a str, NamespaceMember<'a>)> { + let statements = match &ns.body { + // `namespace A.B {}`: `B` is the only member of `A`. + TSNamespaceDeclarationBody::TSNamespaceDeclaration(inner) => { + return std::vec![(inner.id.name.as_str(), NamespaceMember::Namespace(inner))]; + } + TSNamespaceDeclarationBody::TSModuleBlock(block) => &block.body, + }; + let mut members = std::vec::Vec::new(); + for statement in statements { + let decl = match statement { + Statement::ExportDeclaration(export) => &export.declaration, + _ => match statement.as_declaration() { + Some(decl) => decl, + None => continue, + }, + }; + match decl { + Declaration::TSNamespaceDeclaration(inner) => { + members.push((inner.id.name.as_str(), NamespaceMember::Namespace(inner))); + } + Declaration::TSImportEqualsDeclaration(alias) => { + members.push(( + alias.id.name.as_str(), + NamespaceMember::Alias(&alias.module_reference), + )); + } + 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); + members + .extend(bindings.into_iter().map(|(id, _)| (id, NamespaceMember::Other))); + } + } + Declaration::FunctionDeclaration(f) => { + members.extend(f.id.as_ref().map(|id| (id.name.as_str(), NamespaceMember::Other))); + } + Declaration::ClassDeclaration(c) => { + members.extend(c.id.as_ref().map(|id| (id.name.as_str(), NamespaceMember::Other))); + } + Declaration::TSEnumDeclaration(e) => { + members.push((e.id.name.as_str(), NamespaceMember::Other)); + } + Declaration::TSInterfaceDeclaration(i) => { + members.push((i.id.name.as_str(), NamespaceMember::Other)); + } + Declaration::TSTypeAliasDeclaration(t) => { + members.push((t.id.name.as_str(), NamespaceMember::Other)); + } + _ => {} + } + } + members +} + /// A top-level variable binding. enum Variable<'a> { /// A name bound by a declaration with an initializer, and the path to it @@ -577,6 +736,7 @@ impl<'a> FileScope<'a> { Declaration::TSEnumDeclaration(e) => { let id = e.id.name.as_str(); self.enums.entry(id).or_insert(e); + self.enum_declarations.entry(id).or_default().push(e); self.types.entry(id).or_insert(exported); name(self, id); } @@ -592,10 +752,102 @@ impl<'a> FileScope<'a> { self.aliases.entry(alias.id.name.as_str()).or_insert(&alias.module_reference); name(self, alias.id.name.as_str()); } + Declaration::TSNamespaceDeclaration(ns) => { + self.namespaces.entry(ns.id.name.as_str()).or_default().push(ns); + } _ => {} } } + /// Whether the file declares `name` at its top level: a namespace, class, + /// function, variable, enum, interface or type alias. + pub(crate) fn declares(&self, name: &str) -> bool { + self.namespaces.contains_key(name) + || self.types.contains_key(name) + || self.classes.contains_key(name) + || self.enums.contains_key(name) + || self.functions.contains_key(name) + || self.variables.contains_key(name) + } + + /// Whether `path.member` (`["NS", "E"]` and `A` for `NS.E.A`) is a member + /// of an enum the file declares: at its top level, or in a namespace it + /// declares (a class or function merged with one included). + pub(crate) fn is_enum_member(&self, path: &[&str], member: &str) -> bool { + match path { + [] => false, + [name] => self + .enum_declarations + .get(name) + .is_some_and(|decls| decls.iter().any(|e| enum_declares_member(e, member))), + [head, rest @ ..] => self.namespaces.get(head).is_some_and(|decls| { + decls.iter().any(|ns| namespace_declares_enum_member(ns, rest, member)) + }), + } + } + + /// What `name` stands for when it's a top-level import-equals alias. + pub(crate) fn alias(&self, name: &str) -> Option> { + AliasTarget::of(self.aliases.get(name)?) + } + + /// For the qualified name `parts` (head first) whose head is a namespace + /// the file declares, the first member that's an import-equals alias + /// declared in the namespace before it (`NS.A` for + /// `namespace NS { export import A = X; }`): how many parts it covers, and + /// what they stand for. The alias's target is named from inside its + /// namespace, so its head resolves there first, then in the enclosing + /// namespaces, then at the top level: `X` is `["NS", "X"]` when `NS` + /// declares `X`. + pub(crate) fn namespace_alias<'p>(&self, parts: &[&'p str]) -> Option<(usize, AliasTarget<'p>)> + where + 'a: 'p, + { + let mut decls = self.namespaces.get(parts.first()?)?.clone(); + // The members of `parts[..=k]`, for each namespace `k` walked through. + let mut scopes: std::vec::Vec)>> = + std::vec::Vec::new(); + for (i, name) in parts.iter().enumerate().skip(1) { + let members: std::vec::Vec<_> = + decls.iter().flat_map(|ns| namespace_members(ns)).collect(); + let alias = members.iter().find_map(|(n, m)| match m { + NamespaceMember::Alias(reference) if n == name => Some(*reference), + _ => None, + }); + decls = members + .iter() + .filter_map(|(n, m)| match m { + NamespaceMember::Namespace(ns) if n == name => Some(*ns), + _ => None, + }) + .collect(); + scopes.push(members); + if let Some(reference) = alias { + return Some(( + i + 1, + match AliasTarget::of(reference)? { + AliasTarget::Module(m) => AliasTarget::Module(m), + AliasTarget::Entity(target) => { + let head = target[0]; + let path = match scopes + .iter() + .rposition(|members| members.iter().any(|(n, _)| *n == head)) + { + Some(k) => parts[..=k].iter().copied().chain(target).collect(), + None => target, + }; + AliasTarget::Entity(path) + } + }, + )); + } + if decls.is_empty() { + return None; + } + } + None + } + /// 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. @@ -1149,21 +1401,27 @@ const ES_GLOBALS: &[&str] = &[ ]; /// 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", +/// each with a single signature, and the type of its first parameter as +/// TypeScript's `lib.es5.d.ts` declares it; the others are `declare var`s. +const ES_GLOBAL_FUNCTIONS: &[(&str, &str)] = &[ + ("decodeURI", "string"), + ("decodeURIComponent", "string"), + ("encodeURI", "string"), + ("encodeURIComponent", "string | number | boolean"), + ("escape", "string"), + ("eval", "string"), + ("isFinite", "number"), + ("isNaN", "number"), + ("parseFloat", "string"), + ("parseInt", "string"), + ("unescape", "string"), ]; +/// The first parameter type of [`ES_GLOBAL_FUNCTIONS`]'s `name`. +fn es_global_function_param(name: &str) -> Option<&'static str> { + ES_GLOBAL_FUNCTIONS.iter().find(|(n, _)| *n == name).map(|(_, ty)| *ty) +} + /// Bounds a chain of import-equals aliases (`import A = B; import B = A;`). const MAX_ALIASES: u16 = 64; @@ -2344,7 +2602,7 @@ pub(crate) fn transform_error<'a>( // 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()) => + if es_global_function_param(name).is_some() => { return clash(); } @@ -3065,3 +3323,102 @@ fn loose_equals(a: &Value<'_>, b: &Value<'_>) -> bool { _ => to_number(a) == to_number(b), } } + +// ============================================================================= +// `.d.ts` input transform types +// ============================================================================= + +/// An input transform whose first parameter the `.d.ts` can be typed with. +#[derive(Clone, Copy)] +enum Transform<'a> { + /// A function defined in this file (its first declaration). + Def(FnDef<'a>), + /// A function of TypeScript's library: the type of its first parameter. + Lib(&'static str), +} + +/// The `.d.ts` type of each input transform's first parameter, for +/// `static ngAcceptInputType_: T;`. +/// +/// Only transforms defined in this file or in TypeScript's library +/// ([`ES_GLOBAL_FUNCTIONS`]) are covered; imported ones can't be inspected and +/// are left out. +pub fn input_transform_types<'a>( + class: &'a Class<'a>, + consts: &super::StringConsts<'a>, + source: &'a str, +) -> HashMap { + let evaluator = Evaluator::new(consts); + let scope = consts.scope(); + // The transform that ends up compiled for each input: a member `@Input` + // overrides an `inputs:` entry, like the compiled inputs map. + let mut transforms: std::vec::Vec<(String, Option>)> = std::vec::Vec::new(); + let mut record = |name: String, value: &Value<'a>| { + let transform = match value { + Value::Function(def) => Some(Transform::Def(*def)), + // An overloaded function or static method is typed from its first + // declaration, as ngtsc does. + Value::Reference { name, kind: RefKind::Function(function, _) } => { + Some(Transform::Def(FnDef::Function(scope.first_declaration(name, function)))) + } + Value::Reference { name, kind: RefKind::Global } => { + es_global_function_param(name).map(Transform::Lib) + } + _ => None, + }; + match transforms.iter_mut().find(|(n, _)| *n == name) { + Some(entry) => entry.1 = transform, + None => transforms.push((name, transform)), + } + }; + if let Some((Some(config), _)) = super::angular_decorator_config(class) + && let Some(inputs) = super::decorator::config_property(config, "inputs", consts) + && let Value::Array(items) = evaluator.evaluate(inputs) + { + for item in &items { + if let (Some(Value::String(name)), Some(transform)) = + (item.prop("name").map(|p| &p.value), item.prop("transform")) + { + record(name.clone(), &transform.value); + } + } + } + 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 }; + if let Some(options) = + super::property_decorators::input_decorator_options(decorators, consts) + && let Some(transform) = evaluator.evaluate(options).prop("transform") + { + record(name.to_string(), &transform.value); + } + } + + transforms + .into_iter() + .filter_map(|(name, transform)| { + let def = match transform? { + Transform::Def(def) => def, + Transform::Lib(ty) => return Some((name, ty.to_string())), + }; + let ty = match def.first_param_type().ok()? { + Some(ty) => { + let mut printer = + super::dts_type::TypePrinter { scope, source, other_module: false }; + let ty = printer.print(ty); + if printer.other_module { + return None; + } + ty + } + None => "unknown".to_string(), + }; + Some((name, ty)) + }) + .collect() +} diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index f058580e3..4cd74b510 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 dts_type; mod evaluator; mod metadata; mod property_decorators; @@ -37,6 +38,8 @@ pub(crate) use decorator::{ merge_by_class_property, parse_decorator_io, resolve_member_transforms, }; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; +pub(crate) use dts_type::quote as ts_string_literal; +pub use evaluator::input_transform_types; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, diff --git a/crates/oxc_angular_compiler/src/dts.rs b/crates/oxc_angular_compiler/src/dts.rs index c062e73fd..fb8d238cd 100644 --- a/crates/oxc_angular_compiler/src/dts.rs +++ b/crates/oxc_angular_compiler/src/dts.rs @@ -14,12 +14,13 @@ //! `packages/compiler-cli/src/ngtsc/transform/src/declaration.ts` use crate::component::{ComponentMetadata, HostDirectiveMetadata, R3DependencyMetadata}; -use crate::directive::{R3DirectiveMetadata, R3InputMetadata}; +use crate::directive::{R3DirectiveMetadata, R3InputMetadata, ts_string_literal}; use crate::injectable::InjectableMetadata; use crate::ng_module::NgModuleMetadata; use crate::pipe::PipeMetadata; use crate::service::ServiceMetadata; use oxc_str::Ident; +use std::collections::HashMap; /// A `.d.ts` type declaration for an Angular class. /// @@ -55,6 +56,7 @@ pub fn generate_component_dts( content_query_names: &[String], has_injectable: bool, ng_content_selectors: &[String], + accept_types: &HashMap, ) -> DtsDeclaration { let class_name = metadata.class_name.as_str(); let type_with_params = type_with_parameters(class_name, type_argument_count); @@ -73,7 +75,7 @@ pub fn generate_component_dts( let cleaned = s.as_str().replace('\n', ""); format!("\"{}\"", escape_dts_string(&cleaned)) } - None => "never".to_string(), + None => "\"ng-component\"".to_string(), }; let export_as = if metadata.export_as.is_empty() { @@ -155,7 +157,7 @@ pub fn generate_component_dts( } // Add ngAcceptInputType_* fields for non-signal inputs with transform functions - generate_input_transform_fields(&metadata.inputs, &mut members); + generate_input_transform_fields(&metadata.inputs, accept_types, &mut members); DtsDeclaration { class_name: class_name.to_string(), members } } @@ -172,6 +174,7 @@ pub fn generate_component_dts( pub fn generate_directive_dts( metadata: &R3DirectiveMetadata, has_injectable: bool, + accept_types: &HashMap, ) -> DtsDeclaration { let class_name = metadata.name.as_str(); let type_with_params = type_with_parameters(class_name, metadata.type_argument_count); @@ -259,7 +262,7 @@ pub fn generate_directive_dts( } // Add ngAcceptInputType_* fields for non-signal inputs with transform functions - generate_input_transform_fields(&metadata.inputs, &mut members); + generate_input_transform_fields(&metadata.inputs, accept_types, &mut members); DtsDeclaration { class_name: class_name.to_string(), members } } @@ -551,23 +554,31 @@ fn generate_ctor_deps_type_from_factory_deps( /// Generate `ngAcceptInputType_*` static fields for non-signal inputs with transform functions. /// /// When an input has a `transform` function (e.g., `@Input({transform: booleanAttribute})`), -/// Angular generates a static field like: +/// Angular generates a static field typed with the transform's first parameter: /// ```text -/// static ngAcceptInputType_disabled: unknown; +/// static ngAcceptInputType_disabled: boolean | string; /// ``` /// This enables template type-checking to know that transformed inputs accept wider types. /// /// Signal inputs do NOT generate these fields (they capture WriteT within the InputSignal type). /// -/// Note: We use `unknown` as the type because we don't have access to the TypeScript type checker -/// to determine the actual write type of the transform function. -fn generate_input_transform_fields(inputs: &[R3InputMetadata], members: &mut String) { +/// `accept_types` maps input names to that type for transforms defined in the +/// same file; the rest (imported transforms) fall back to `unknown`, since their +/// signature isn't visible here. +fn generate_input_transform_fields( + inputs: &[R3InputMetadata], + accept_types: &HashMap, + members: &mut String, +) { for input in inputs { if !input.is_signal && input.transform_function.is_some() { - members.push_str(&format!( - "\nstatic ngAcceptInputType_{}: unknown;", - input.class_property_name.as_str() - )); + let name = input.class_property_name.as_str(); + let field = format!("ngAcceptInputType_{name}"); + // ngtsc quotes the name only when Angular's `isUnsafeObjectKey` + // (`/[-.]/`) matches; any other name is printed as written. + let field = if field.contains(['-', '.']) { ts_string_literal(&field) } else { field }; + let ty = accept_types.get(name).map_or("unknown", String::as_str); + members.push_str(&format!("\nstatic {field}: {ty};")); } } } 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 41ab4d0c2..91101319e 100644 --- a/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs +++ b/crates/oxc_angular_compiler/tests/decorator_metadata_ngtsc_test.rs @@ -275,6 +275,14 @@ fn decorator_metadata_matches_ngtsc() { 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() { + // A `static ngAcceptInputType_*` the class declares itself comes + // from TypeScript's declaration emit, not from ngtsc; oxc only + // writes the members it generates. + if key.starts_with("dts:ngAcceptInputType_") + && source.contains(&format!("static {}:", &key["dts:".len()..])) + { + continue; + } let expected = accept_type_without_other_modules(key, value.as_str().unwrap()); let expected = expected.as_str(); let actual = @@ -299,7 +307,7 @@ fn decorator_metadata_matches_ngtsc() { failures.len(), failures.join("\n\n") ); - assert_eq!(compared, 754, "fixtures compared"); + assert_eq!(compared, 794, "fixtures compared"); } fn transform(source: &str) -> TransformResult { @@ -801,8 +809,11 @@ 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); + let result = transform(source); + let code = strip(&result.code); assert!(code.contains(r#"inputs:{x:[2,"x","x",Utils.coerce]}"#), "{code}"); + let dts = &result.dts_declarations[0].members; + assert!(dts.contains("static ngAcceptInputType_x: boolean;"), "{dts}"); } /// A transform read through a namespace import (`core.booleanAttribute`): diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs new file mode 100644 index 000000000..0f9721561 --- /dev/null +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -0,0 +1,1100 @@ +//! `static ngAcceptInputType_: T;` in the `.d.ts`: `T` is the input +//! transform's first parameter type, printed the way ngtsc prints it (its +//! `TypeEmitter` plus TypeScript's printer). +//! +//! Every expected string below is what `@angular/compiler-cli` 22.1.7 wrote +//! into the `.d.ts` for [`SOURCE`] with the type in place of `%TYPE%`. + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, transform_angular_file}; + +const SOURCE: &str = "import {Directive, Input, Signal, ElementRef, booleanAttribute, Signal as S} from '@angular/core'; +import type {TemplateRef, WritableSignal} from '@angular/core'; +import * as ng from '@angular/core'; +import type {ToSignalOptions} from '@angular/core/rxjs-interop'; +import {Other} from './other'; +import * as oth from './other'; +export type TT = 'a' | 'b'; +export interface Box { b: 1 } +export enum Color { Red, Green } +export const enum CE { A = 'a' } +export interface Gen { v: T } +export const val = {a: 1}; +export const kc = 'kc'; +export function gen(x: T) { return x; } +@Directive({selector: '[t]'}) +export class T { + @Input({transform: (v: %TYPE%) => 1}) t: any; +} +"; + +/// The type oxc writes for `ngAcceptInputType_t` when the transform's +/// parameter has type `ty`. +fn accept_type(ty: &str) -> String { + let source = SOURCE.replace("%TYPE%", ty); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "t.ts", + &source, + Some(&TransformOptions::default()), + None, + ); + let decl = result + .dts_declarations + .iter() + .find(|d| d.class_name == "T") + .unwrap_or_else(|| panic!("no .d.ts declaration for `{ty}`")); + let marker = "static ngAcceptInputType_t: "; + let at = decl + .members + .find(marker) + .unwrap_or_else(|| panic!("no ngAcceptInputType_t for `{ty}`:\n{}", decl.members)); + let rest = &decl.members[at + marker.len()..]; + rest.trim_end().strip_suffix(';').unwrap_or(rest).to_string() +} + +fn check(cases: &[(&str, &str)]) { + let failures: Vec = cases + .iter() + .filter_map(|(ty, expected)| { + let actual = accept_type(ty); + (actual != *expected) + .then(|| format!(" {ty:?}\n expected {expected:?}\n actual {actual:?}")) + }) + .collect(); + assert!(failures.is_empty(), "{} mismatches:\n{}", failures.len(), failures.join("\n")); +} + +#[test] +fn matches_ngtsc() { + check(MATCH); +} + +/// Comments ngtsc keeps that oxc drops: `/** */` comments after a type or +/// on their own line (TypeScript's printer keeps only those two kinds of +/// comment around nodes), and comments spanning lines. Each row is the type, +/// what ngtsc writes and what oxc writes; they differ only by the comment. +const COMMENTS_NOT_KEPT: &[(&str, &str, &str)] = &[ + ("{\n /** doc */\n a: 1\n}", "{ \n /** doc */\n a: 1; }", "{ a: 1; }"), + ("string | /* a\n b */ number", "string | /* a\n b */ number", "string | number"), + ("string /** d */ | number", "string /** d */ | number", "string | number"), + ("{ a: 1 /** y */ }", "{ a: 1; /** y */ }", "{ a: 1; }"), + ("(x: string /** y */) => void", "(x: string /** y */) => void", "(x: string) => void"), + ("Array", "Array", "Array"), + ("string |\n /** j */ number", "string | \n /** j */ number", "string | number"), + // TypeScript reads past a U+2028 / U+2029 for the comments after `<` (it + // only stops at `\n` and `\r`), and prints this one twice. + ( + "Array<\u{2028}/* c */\nstring | number>", + "Array", + "Array", + ), + ( + "Array<\u{2029}/* c */\nstring | number>", + "Array", + "Array", + ), +]; + +#[test] +fn comments_not_kept() { + // Without comments and whitespace, ngtsc's text is oxc's. + let bare = |text: &str| { + let mut out = String::new(); + let mut rest = text; + while let Some(at) = rest.find("/*") { + out.push_str(&rest[..at]); + rest = &rest[at + rest[at..].find("*/").unwrap() + 2..]; + } + out.push_str(rest); + out.split_whitespace().collect::() + }; + for (ty, ngtsc, oxc) in COMMENTS_NOT_KEPT { + assert_eq!(bare(ngtsc), bare(oxc), "{ty:?}"); + } + let cases: Vec<(&str, &str)> = + COMMENTS_NOT_KEPT.iter().map(|(ty, _, oxc)| (*ty, *oxc)).collect(); + check(&cases); +} + +/// ngtsc adds `import * as i1 from './other'` and writes `i1.Other`; oxc +/// writes `unknown` for any type that names another module (see `dts_type.rs`). +#[test] +fn other_module_types_are_unknown() { + let cases: Vec<(&str, &str)> = OTHER_MODULE.iter().map(|(ty, _)| (*ty, "unknown")).collect(); + check(&cases); +} + +/// ngtsc throws "Unable to emit import type" and aborts the whole build. +#[test] +fn import_types_are_unknown() { + check(&[("import('./other').Other", "unknown"), ("typeof import('./other')", "unknown")]); +} + +/// An enum member (`Color.Red`): ngtsc 22.1.7 throws while emitting the +/// `.d.ts` ("Unsupported WrappedNodeExpr in TypeTranslatorVisitor: +/// EnumMember"), so there's nothing to match. oxc keeps the type as written +/// rather than shortening it to `Red` like other qualified names the file +/// declares (`NS.T` is written `T`, see the snapshot's `probe: dts-qualified*`). +#[test] +fn enum_member_type_is_kept_as_written() { + check(&[("Color.Red", "Color.Red"), ("Color.Red | Color.Green", "Color.Red | Color.Green")]); +} + +/// The same for a member of an enum declared in a namespace (`NS.E.A`, also +/// through a class merged with the namespace, nested namespaces and +/// `namespace A.B`): ngtsc 22.1.7 throws there too. A qualified name that +/// isn't an enum member is still shortened like ngtsc does (`NS.E` is `E`, +/// `A.B.T` is `T`). +#[test] +fn nested_enum_member_type_is_kept_as_written() { + let source = "import {Directive, Input} from '@angular/core'; +export namespace NS { export enum E { A = 'a' } export namespace M { export enum E { B = 'b' } } } +export class C {} +export namespace C { export enum E { A = 'a' } } +export namespace A.B { export enum E { X = 'x' } export type T = string; } +@Directive({selector: '[d]'}) +export class Dir { + @Input({transform: (v: NS.E.A | C.E.A | NS.M.E.B | A.B.E.X) => 1}) x!: number; + @Input({transform: (v: NS.E | NS.M.E | A.B.T) => 1}) y!: number; +} +"; + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "t.ts", + source, + Some(&TransformOptions::default()), + None, + ); + let decl = result.dts_declarations.iter().find(|d| d.class_name == "Dir").unwrap(); + assert!( + decl.members.contains("static ngAcceptInputType_x: NS.E.A | C.E.A | NS.M.E.B | A.B.E.X;"), + "{}", + decl.members + ); + assert!(decl.members.contains("static ngAcceptInputType_y: E | E | T;"), "{}", decl.members); +} + +/// The same whatever the member's name is written as: `'B'`, `['C']` or a +/// template literal (`` [`A`] ``, also with an escape), at the top level or in +/// a namespace. ngtsc 22.1.7 throws on all of these. +#[test] +fn enum_members_named_by_literals_are_kept_as_written() { + let source = "import {Directive, Input} from '@angular/core'; +export enum E { [`A`] = 'a', 'B' = 'b', ['C'] = 'c', [`\\u0044`] = 'd' } +export namespace NS { export enum F { [`A`] = 'a' } } +@Directive({selector: '[d]'}) +export class Dir { + @Input({transform: (v: E.A | E.B | E.C | E.D) => 1}) x!: number; + @Input({transform: (v: NS.F.A) => 1}) y!: number; +} +"; + assert_eq!( + accept_members(source), + vec![ + "static ngAcceptInputType_x: E.A | E.B | E.C | E.D;".to_string(), + "static ngAcceptInputType_y: NS.F.A;".to_string(), + ] + ); +} + +/// A transform declared in a namespace (reached through `typeof NS.fn`, a +/// static method of a class there, or `import H = NS.fn`) is typed from its +/// declaration, like ngtsc 22.1.7 types this source. Its parameter's types +/// are printed as written: ngtsc writes the namespace's `T` as `T` too, which +/// doesn't resolve in the `.d.ts` (the snapshot skips these, since ngtsc also +/// emits `fn` for `F`, which isn't in scope; oxc keeps `F`). +#[test] +fn transforms_declared_in_namespaces_are_typed_like_ngtsc() { + let source = "import {Directive, Input} from '@angular/core'; +export namespace NS { + export type T = string; + export function fn(v: string) { return 1; } + export function g(v: T) { return 1; } + export class C { static s(v: number) { return 1; } } +} +declare const F: typeof NS.fn; +declare const G: typeof NS.g; +declare const K: typeof NS.C; +import H = NS.fn; +@Directive({selector: '[d]'}) +export class Dir { + @Input({transform: F}) x0!: number; + @Input({transform: G}) x1!: number; + @Input({transform: K.s}) x2!: number; + @Input({transform: H}) x3!: number; +} +"; + assert_eq!(accept_members(source), expect_members(&["string", "T", "number", "string"])); +} + +/// The `ngAcceptInputType_*` members oxc writes for `source`, in order. +fn accept_members(source: &str) -> Vec { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "t.ts", + source, + Some(&TransformOptions::default()), + None, + ); + let decl = result.dts_declarations.iter().find(|d| d.class_name == "Dir").unwrap(); + decl.members + .lines() + .map(str::trim) + .filter(|l| l.starts_with("static ngAcceptInputType_")) + .map(str::to_string) + .collect() +} + +/// `x0`, `x1`, ... typed with `types`, one member each. +fn members_typed(types: &[&str]) -> String { + types + .iter() + .enumerate() + .map(|(i, t)| format!(" @Input({{transform: (v: {t}) => 1}}) x{i}!: number;\n")) + .collect() +} + +fn expect_members(types: &[&str]) -> Vec { + types.iter().enumerate().map(|(i, t)| format!("static ngAcceptInputType_x{i}: {t};")).collect() +} + +/// An `import A = NS` / `import C = NS.T` alias (`export import` too) is +/// written as the declaration it resolves to: ngtsc 22.1.7 wrote each +/// expected type for this source. A `typeof` stays as written, like any other. +#[test] +fn import_equals_aliases_are_typed_like_their_target() { + let types = [ + "A.T", + "B.U", + "C", + "Array", + "D.T", + "E", + "A.In.U", + "F.U", + "G", + "typeof A.x", + "{ [k: string]: C }", + ]; + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +export namespace NS {{ export interface T {{ a: string }} export namespace In {{ export interface U {{ a: string }} }} export const x = 1; }} +import A = NS; +import B = NS.In; +import C = NS.T; +export import D = NS; +export import E = NS.T; +import F = A.In; +import G = B.U; +@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + members_typed(&types) + ); + assert_eq!( + accept_members(&source), + expect_members(&[ + "T", + "U", + "T", + "Array", + "T", + "T", + "U", + "U", + "U", + "typeof A.x", + "{ [k: string]: T; }" + ]) + ); +} + +/// A chain of aliases is followed to its end however long it is: ngtsc 22.1.7 +/// writes `T` for `A17.T` through 18 aliases, and `T | number` through 40. A +/// cycle (a TypeScript error, on which ngtsc throws) gives `unknown`. +#[test] +fn import_equals_alias_chains_are_followed_to_the_end() { + let chain = + |n: usize| -> String { (1..n).map(|i| format!("import A{i} = A{};\n", i - 1)).collect() }; + let types = ["A17.T", "A39.T | number", "X.T", "S.T"]; + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +namespace NS {{ export type T = string; }} +import A0 = NS; +{}import X = Y; +import Y = X; +import S = S.B; +@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + chain(40), + members_typed(&types) + ); + assert_eq!(accept_members(&source), expect_members(&["T", "T | number", "unknown", "unknown"])); +} + +/// An alias of another module (`import R = require('./other')`, or of an +/// import) follows the rule for other modules: `unknown`. ngtsc 22.1.7 writes +/// the bare name there (`Other`, `T`), which doesn't resolve in its `.d.ts`. +/// An alias of `@angular/core`, through a namespace import or +/// `import Core = require('@angular/core')`, becomes `i0.X` (ngtsc: the bare +/// `Signal`, equally unresolved; it throws on a `require` declared in +/// a namespace, which TypeScript also rejects), and one of a global is written +/// as its target (`Intl.NumberFormat`, where ngtsc writes `NumberFormat`). +#[test] +fn import_equals_aliases_of_other_modules_and_globals() { + let cases: [(&str, &[&str], &[&str]); 5] = [ + ( + "import R = require('./other');\n", + &["R.Other", "R.Inner.T", "typeof R.FLAG", "R.Other | string"], + &["unknown", "unknown", "typeof R.FLAG", "unknown"], + ), + ( + "import * as o from './other';\nimport {Inner} from './other';\nimport N = o.Inner;\nimport M = Inner;\nimport K = o;\n", + &["N.T", "M.T", "K.Other", "K.Inner.T"], + &["unknown", "unknown", "unknown", "unknown"], + ), + ( + "import * as ng from '@angular/core';\nimport S = ng.Signal;\nimport Core = ng;\n", + &["S", "Core.Signal", "Core.ElementRef"], + &["i0.Signal", "i0.Signal", "i0.ElementRef"], + ), + ( + "import Core = require('@angular/core');\nimport S = Core.Signal;\nimport C2 = Core;\nexport import Ex = require('@angular/core');\nnamespace NS { export import S2 = Core.Signal; export import C3 = Core; }\nnamespace NR { export import Core2 = require('@angular/core'); }\n", + &[ + "Core.Signal", + "Core.ElementRef | null", + "S", + "C2.Signal", + "Ex.ElementRef", + "NS.S2", + "NS.C3.ElementRef", + "NR.Core2.Signal", + "typeof Core.booleanAttribute", + ], + &[ + "i0.Signal", + "i0.ElementRef | null", + "i0.Signal", + "i0.Signal", + "i0.ElementRef", + "i0.Signal", + "i0.ElementRef", + "i0.Signal", + "typeof Core.booleanAttribute", + ], + ), + ( + "import NF = Intl.NumberFormat;\nimport I = Intl;\n", + &["NF", "I.NumberFormat"], + &["Intl.NumberFormat", "Intl.NumberFormat"], + ), + ]; + for (imports, types, expected) in cases { + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +{imports}@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + members_typed(types) + ); + assert_eq!(accept_members(&source), expect_members(expected), "{imports}"); + } +} + +/// Declarations for [`namespace_member_aliases_are_typed_like_their_target`] +/// and [`namespace_member_aliases_of_other_modules_and_globals`]: import-equals +/// aliases declared in namespaces, of names that namespace, an enclosing one +/// or the file declares. +const NAMESPACE_ALIASES: &str = "import * as ng from '@angular/core'; +import * as oth from './other'; +export class Local { a = 1; } +export namespace Local { export interface M { m: 1 } } +export function Fn() {} +export namespace Fn { export interface Z { z: 1 } } +export enum En { A } +export namespace En { export interface Q { q: 1 } } +export namespace Other { export interface T { c: 1 } export namespace Deep { export interface U { d: 1 } } } +export namespace NS { + export import Alias = Local; + export namespace Inner { export import Alias2 = Local; export import Up = Alias; } + export import A1 = Local; + export import A2 = A1; + export import OT = Other.T; + export import O = Other; + export import OD = Other.Deep; + export class In2 { i = 1; } + export namespace In2 { export interface W { w: 1 } } + export import Rel = In2; + export import FA = Fn; + export import EA = En; + export import Core = ng; + export import CoreE = ng.ElementRef; + export import Oth = oth; + export import OthT = oth.FooNs.T; + export import NF = Intl.NumberFormat; + export import G = Inner.Alias2; + export import N2 = NS2; +} +export namespace NS2 { export import B = NS.Alias; export interface V { v: 1 } } +export class Local3 { b = 1; } +export namespace Local3 { export import Self = Local3; export import MM = Local.M; } +import X = NS.Alias; +import Y = NS.Inner; +"; + +/// `accept_members` for [`NAMESPACE_ALIASES`] with `types`. +fn namespace_alias_members(types: &[&str]) -> Vec { + accept_members(&format!( + "import {{Directive, Input}} from '@angular/core'; +{NAMESPACE_ALIASES}@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + members_typed(types) + )) +} + +/// An alias declared in a namespace (`NS.Alias`, `NS.Inner.Alias2`, an alias +/// of an alias, or one whose target is named from the namespace's scope) is +/// written as the declaration it resolves to: ngtsc 22.1.7 wrote each expected +/// type for [`NAMESPACE_ALIASES`], one type per compile. +#[test] +fn namespace_member_aliases_are_typed_like_their_target() { + let cases = [ + ("NS.Alias", "Local"), + ("NS.Alias.M", "M"), + ("NS.Inner.Alias2", "Local"), + ("NS.Inner.Alias2.M", "M"), + ("NS.Inner.Up", "Local"), + ("NS.A2", "Local"), + ("NS.A2.M", "M"), + ("NS.OT", "T"), + ("NS.O.T", "T"), + ("NS.O.Deep.U", "U"), + ("NS.OD.U", "U"), + ("NS.Rel", "In2"), + ("NS.Rel.W", "W"), + ("NS.In2", "In2"), + ("NS.FA.Z", "Z"), + ("NS.EA", "En"), + ("NS.EA.Q", "Q"), + ("NS.G", "Local"), + ("NS.N2.V", "V"), + ("NS.N2.B", "Local"), + ("NS2.B", "Local"), + ("Local3.Self", "Local3"), + ("Local3.MM", "M"), + ("X", "Local"), + ("X.M", "M"), + ("Y.Alias2", "Local"), + ("Y.Up", "Local"), + ("Array", "Array"), + ("typeof NS.Alias", "typeof NS.Alias"), + ("NS.Alias | NS.A2", "Local | Local"), + ("{ k: NS.Inner.Alias2 }", "{ k: Local; }"), + ]; + let types: Vec<&str> = cases.iter().map(|(ty, _)| *ty).collect(); + let expected: Vec<&str> = cases.iter().map(|(_, expected)| *expected).collect(); + assert_eq!(namespace_alias_members(&types), expect_members(&expected)); +} + +/// A namespace member alias of another module, `@angular/core` or a global +/// follows the rules for a top-level alias (see +/// `import_equals_aliases_of_other_modules_and_globals`). ngtsc 22.1.7 writes +/// the bare `ElementRef`, `Foo`, `T` (none of which resolve in its `.d.ts`) +/// and `NumberFormat`. It throws on the enum member `NS.EA.A`, which oxc +/// writes as the member it resolves to, like `E.A`. +#[test] +fn namespace_member_aliases_of_other_modules_and_globals() { + let cases = [ + ("NS.Core.ElementRef", "i0.ElementRef"), + ("NS.CoreE", "i0.ElementRef"), + ("NS.Oth.Foo", "unknown"), + ("NS.OthT", "unknown"), + ("NS.NF", "Intl.NumberFormat"), + ("NS.EA.A", "En.A"), + ]; + let types: Vec<&str> = cases.iter().map(|(ty, _)| *ty).collect(); + let expected: Vec<&str> = cases.iter().map(|(_, expected)| *expected).collect(); + assert_eq!(namespace_alias_members(&types), expect_members(&expected)); +} + +/// A transform that's a global declared outside the file (the DOM's `atob`, a +/// project's `declare function`) is assumed to be a function but can't be +/// inspected, so its type is `unknown`, like an imported transform's. ngtsc +/// 22.1.7 reads the lib and writes `string` for `atob` and `any` for `alert`. +/// A function of TypeScript's ES lib (`parseInt`) is typed. +#[test] +fn global_transforms_declared_outside_the_file_are_unknown() { + let source = "import {Directive, Input} from '@angular/core'; +const t = atob; +@Directive({selector: '[d]', inputs: [{name: 'x0', transform: alert}]}) +export class Dir { + x0!: string; + @Input({transform: atob}) x1!: string; + @Input({transform: t}) x2!: string; + @Input({transform: parseInt}) x3!: number; +} +"; + assert_eq!( + accept_members(source), + expect_members(&["unknown", "unknown", "unknown", "string"]) + ); +} + +/// A name in a namespace merged with an enum (`E.T`) is shortened like any +/// other qualified local name, as ngtsc 22.1.7 does: only an actual enum +/// member (`E.A`, also from a second declaration of the enum) is kept as +/// written. +#[test] +fn names_in_a_namespace_merged_with_an_enum_are_shortened() { + let types = ["E.T", "E.U", "E.N.V", "NS.F.T", "E.T | E.U", "E.A", "E.B", "NS.F.X"]; + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +export enum E {{ A }} +export enum E {{ B = 1 }} +export namespace E {{ export interface T {{ a: string }} export type U = string; export namespace N {{ export interface V {{ a: 1 }} }} }} +export namespace NS {{ export enum F {{ X }} export namespace F {{ export interface T {{ a: 1 }} }} }} +@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + members_typed(&types) + ); + assert_eq!( + accept_members(&source), + expect_members(&["T", "U", "V", "T", "T | U", "E.A", "E.B", "NS.F.X"]) + ); +} + +/// A computed property name follows the rules of a type name (see +/// `dts_type.rs`): ngtsc 22.1.7 copies the expression as written, so an +/// imported name (`{ [token]: 1; }`) doesn't resolve in its `.d.ts`. oxc +/// writes `@angular/core` names through `i0` and makes a type naming another +/// module `unknown`. Local and global names are written as is, like ngtsc +/// (`{ [kc]: 1 }` and `[Symbol.iterator]` in [`MATCH`]). +#[test] +fn computed_names_from_imports_follow_type_name_rules() { + check(&[ + ("{ [Other]: 1 }", "unknown"), + ("{ [oth.token]: 1 }", "unknown"), + ("{ [oth.a.token](): void }", "unknown"), + ("(x: { [Other]: 1 }) => void", "unknown"), + ("{ [booleanAttribute]: 1 }", "{ [i0.booleanAttribute]: 1; }"), + ("{ [ng.ɵSIGNAL]: 1 }", "{ [i0.ɵSIGNAL]: 1; }"), + ]); +} + +/// A computed property name through an import-equals alias (top level, in a +/// namespace, or of an alias) resolves it the way a type name does. ngtsc +/// 22.1.7 copies every one of these as written. An alias of a name the file +/// declares stays as written, as ngtsc wrote it (the last group). The rest +/// follow oxc's rules for imports and globals, since type-checking ngtsc's +/// `.d.ts` reports "Cannot find name" for each (except `Ex`, which is +/// exported): an alias of `@angular/core` gives `i0.X`, one of another module +/// makes the type `unknown`, and one of a global (`G`, declared in a separate +/// `.d.ts` for ngtsc) is written as its target. +#[test] +fn computed_names_through_import_equals_aliases() { + let cases: [(&str, &[&str], &[&str]); 4] = [ + ( + "import Core = require('@angular/core');\nimport S = Core.ɵSIGNAL;\nimport C2 = Core;\nexport import Ex = require('@angular/core');\nimport * as ng from '@angular/core';\nimport C = ng;\nimport S2 = ng.ɵSIGNAL;\nnamespace W { export import C3 = Core; export import S3 = Core.ɵSIGNAL; }\n", + &[ + "{ [Core.ɵSIGNAL]: string }", + "{ [Core.ɵSIGNAL](): void }", + "{ [S]: string }", + "{ [C2.ɵSIGNAL]: string }", + "{ [Ex.ɵSIGNAL]: string }", + "{ [C.ɵSIGNAL]: string }", + "{ [S2]: string }", + "{ [W.C3.ɵSIGNAL]: string }", + "{ [W.S3]: string }", + ], + &[ + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL](): void; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + "{ [i0.ɵSIGNAL]: string; }", + ], + ), + ( + "import R = require('./other');\nimport * as o from './other';\nimport O = o;\nimport T = o.token;\nimport {Inner} from './other';\nimport I = Inner;\nnamespace W { export import O2 = o; }\n", + &[ + "{ [R.token]: string }", + "{ [R.Inner.k]: string }", + "{ [O.token]: string }", + "{ [T]: string }", + "{ [I.k]: string }", + "{ [W.O2.token]: string }", + ], + &["unknown", "unknown", "unknown", "unknown", "unknown", "unknown"], + ), + ( + "import GA = G;\nimport GK = G.k;\nimport GI = G.In;\nnamespace W { export import GA2 = G; }\n", + &[ + "{ [GA.k]: string }", + "{ [GK]: string }", + "{ [GI.j]: string }", + "{ [W.GA2.k]: string }", + ], + &[ + "{ [G.k]: string; }", + "{ [G.k]: string; }", + "{ [G.In.j]: string; }", + "{ [G.k]: string; }", + ], + ), + ( + "namespace NS { export const k = 'nk'; }\nimport A = NS;\nimport K = NS.k;\nnamespace W { export import A2 = NS; }\n", + &["{ [A.k]: string }", "{ [K]: string }", "{ [W.A2.k]: string }"], + &["{ [A.k]: string; }", "{ [K]: string; }", "{ [W.A2.k]: string; }"], + ), + ]; + for (imports, types, expected) in cases { + let source = format!( + "import {{Directive, Input}} from '@angular/core'; +{imports}@Directive({{selector: '[d]'}}) +export class Dir {{ +{}}} +", + members_typed(types) + ); + assert_eq!(accept_members(&source), expect_members(expected), "{imports}"); + } +} + +/// An overloaded static method is typed from its first declaration, as an +/// overloaded function is (ngtsc 22.1.7 writes `string` here). The snapshot +/// can't hold this case: ngtsc compiles the transform to the bare method name +/// `c`, which oxc deliberately doesn't. +#[test] +fn overloaded_static_method_is_typed_from_its_first_declaration() { + let source = "import {Directive, Input} from '@angular/core'; +export class U { static c(v: string): number; static c(v: string | number) { return 1; } } +@Directive({selector: '[d]'}) +export class Dir { + @Input({transform: U.c}) x!: number; +} +"; + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "t.ts", + source, + Some(&TransformOptions::default()), + None, + ); + let decl = result.dts_declarations.iter().find(|d| d.class_name == "Dir").unwrap(); + assert!(decl.members.contains("static ngAcceptInputType_x: string;"), "{}", decl.members); +} + +/// The type in the transform, and what ngtsc writes for it. +const MATCH: &[(&str, &str)] = &[ + ("{[K in 'a']: Signal}", "{ [K in \"a\"]: i0.Signal; }"), + ("{[K in TT]: Signal}", "{ [K in TT]: i0.Signal; }"), + ("{readonly [K in TT]?: Signal}", "{ readonly [K in TT]?: i0.Signal; }"), + ("{-readonly [K in TT]-?: Signal}", "{ -readonly [K in TT]-?: i0.Signal; }"), + ("{+readonly [K in TT]+?: Signal}", "{ +readonly [K in TT]+?: i0.Signal; }"), + ("{[K in TT as 'z']: Signal}", "{ [K in TT as \"z\"]: i0.Signal; }"), + ("{[K in keyof Box]: Signal}", "{ [K in keyof Box]: i0.Signal; }"), + ("{[K in TT]: Signal} | null", "{ [K in TT]: i0.Signal; } | null"), + ("{[K in TT]}", "{ [K in TT]: ; }"), + ("{[K in TT]: Signal;}", "{ [K in TT]: i0.Signal; }"), + ("{ m(): Signal }", "{ m(): i0.Signal; }"), + ("{ m?(): Signal }", "{ m?(): i0.Signal; }"), + ( + "{ m(a: Signal, b?: string, ...c: number[]): void }", + "{ m(a: i0.Signal, b?: string, ...c: number[]): void; }", + ), + ("{ m(this: Signal): void }", "{ m(this: i0.Signal): void; }"), + ("{ m>(): void }", "{ m>(): void; }"), + ("{ 'n'(): void; ['c'](): void; 5(): void }", "{ \"n\"(): void; [\"c\"](): void; 5(): void; }"), + ("{ [k: string]: Signal }", "{ [k: string]: i0.Signal; }"), + ("{ readonly [k: string]: Signal }", "{ readonly [k: string]: i0.Signal; }"), + ("{ (x: Signal): void }", "{ (x: i0.Signal): void; }"), + ("{ >(): void }", "{ >(): void; }"), + ("{ new (x: ElementRef): void }", "{ new (x: i0.ElementRef): void; }"), + ("{ new >(): Box }", "{ new >(): Box; }"), + ( + "{ get x(): Signal; set x(v: Signal) }", + "{ get x(): i0.Signal; set x(v: i0.Signal); }", + ), + ("{ get 'g'(): Signal }", "{ get \"g\"(): i0.Signal; }"), + ("{ m(): void, n: 1 }", "{ m(): void; n: 1; }"), + ("{ m() }", "{ m(); }"), + ("{ a }", "{ a; }"), + ("{ readonly r?: Signal }", "{ readonly r?: i0.Signal; }"), + ("(x: unknown) => x is Signal", "(x: unknown) => x is i0.Signal"), + ( + "(x: unknown) => asserts x is Signal", + "(x: unknown) => asserts x is i0.Signal", + ), + ("(x: unknown) => asserts x", "(x: unknown) => asserts x"), + (">() => void", ">() => void"), + (">() => void", ">() => void"), + ( + " = Signal, U = string>() => void", + " = i0.Signal, U = string>() => void", + ), + ("() => void", "() => void"), + ("(this: Signal) => void", "(this: i0.Signal) => void"), + ("(this: Signal, a: string) => void", "(this: i0.Signal, a: string) => void"), + ("new >() => Box", "new >() => Box"), + ("abstract new () => Signal", "abstract new () => i0.Signal"), + ("({w}: {w: 3}) => void", "({ w }: { w: 3; }) => void"), + ("({w: ww, ...rest}: {w: 3}) => void", "({ w: ww, ...rest }: { w: 3; }) => void"), + ("([a, , b]: [1, 2, 3]) => void", "([a, , b]: [1, 2, 3]) => void"), + ("([a, ...r]: [1, 2]) => void", "([a, ...r]: [1, 2]) => void"), + ("({a: {b}}: {a: {b: 1}}) => void", "({ a: { b } }: { a: { b: 1; }; }) => void"), + ("({}: {}) => void", "({}: {}) => void"), + ("([]: []) => void", "([]: []) => void"), + ("({'q-x': qx}: {'q-x': 1}) => void", "({ \"q-x\": qx }: { \"q-x\": 1; }) => void"), + ("({['c']: cc}: {c: 1}) => void", "({ [\"c\"]: cc }: { c: 1; }) => void"), + ("(x?: Signal) => void", "(x?: i0.Signal) => void"), + ("(x) => void", "(x) => void"), + ("(...xs) => void", "(...xs) => void"), + ("typeof this", "typeof this"), + ("{ a: typeof this }", "{ a: typeof this; }"), + ("typeof this | string", "typeof this | string"), + ("Array", "Array"), + ("'é'", "\"\\u00E9\""), + ("'😀'", "\"\\uD83D\\uDE00\""), + ("'\\0'", "\"\\0\""), + ("'\\x00'", "\"\\0\""), + ("'\\0' | '\\u00001'", "\"\\0\" | \"\\x001\""), + ("'\u{2028}\u{2029}\\u0085'", "\"\\u2028\\u2029\\u0085\""), + // A U+2028 / U+2029 (a line break, but three bytes long) on the line + // before a union. + ("{ a: 1;\u{2028} b:\n string | number }", "{ a: 1; b: string | number; }"), + ("{ a: 1;\u{2029} b:\n string | number }", "{ a: 1; b: string | number; }"), + ("'\\v\\f\\b'", "\"\\v\\f\\b\""), + ("'\\x7f'", "\"\u{7f}\""), + ("'a\"b\\'c`d'", "\"a\\\"b'c`d\""), + ("'\\r\\n'", "\"\\r\\n\""), + ("'\\u{10FFFF}'", "\"\\uDBFF\\uDFFF\""), + ("'\\uD800'", "\"\\uD800\""), + ("'\\uDC00x'", "\"\\uDC00x\""), + ("'a\\uD800b\\uDFFF'", "\"a\\uD800b\\uDFFF\""), + ("'\\u{FFFD}'", "\"\\uFFFD\""), + ("'\\uFFFD\\uD800'", "\"\\uFFFD\\uD800\""), + ("{ '\\uD800': 1 }", "{ \"\\uD800\": 1; }"), + ("{ 'x\\uDBFF': 1; '\\uD800\\uDC00': 2 }", "{ \"x\\uDBFF\": 1; \"\\uD800\\uDC00\": 2; }"), + ( + "({'\\uD800': a}: {'\\uD800': 1}) => void", + "({ \"\\uD800\": a }: { \"\\uD800\": 1; }) => void", + ), + ("`é`", "`é`"), + ("`é${string}`", "`é${string}`"), + ("`aé${number}b`", "`aé${number}b`"), + ( + "{ 'q-x': 1; \"dq\": 2; 3: 4; 0x10: 5; 1.50: 6; 1e3: 7 }", + "{ \"q-x\": 1; \"dq\": 2; 3: 4; 16: 5; 1.5: 6; 1000: 7; }", + ), + ("{ 'é': 1 }", "{ \"\\u00E9\": 1; }"), + ("{ é: 1 }", "{ é: 1; }"), + ("{ 'a\\nb': 1 }", "{ \"a\\nb\": 1; }"), + ("{ ['lit']: 1 }", "{ [\"lit\"]: 1; }"), + ("{ [kc]: 1 }", "{ [kc]: 1; }"), + ("{ [Symbol.iterator]: Signal }", "{ [Symbol.iterator]: i0.Signal; }"), + ("{ m(): void; 'n'?: 2 }", "{ m(): void; \"n\"?: 2; }"), + ( + "1e21 | 0.1 | -1 | 0b101 | 0o17 | 1_000 | 0x1Fn | 123456789012345678901234567890", + "1e+21 | 0.1 | -1 | 5 | 15 | 1000 | 0x1fn | 1.2345678901234568e+29", + ), + ("- 1", "-1"), + ("typeof val", "typeof val"), + ("typeof val.a", "typeof val.a"), + ("typeof gen>", "typeof gen>"), + ("Signal['set']", "i0.Signal[\"set\"]"), + ("TT extends `${infer _}` ? 1 : 2", "TT extends `${infer _}` ? 1 : 2"), + ( + "string extends infer U extends Signal ? 1 : 2", + "string extends infer U extends i0.Signal ? 1 : 2", + ), + ("readonly Signal[]", "readonly i0.Signal[]"), + ("Array<{ m(): Signal }>", "Array<{ m(): i0.Signal; }>"), + ("[Signal?, ...Signal[]]", "[i0.Signal?, ...i0.Signal[]]"), + ("/* c */ string", "string"), + ("string /* d */", "string"), + ("/* c */ string /* d */ | number", "/* c */ string | number"), + ("string | /* e */ number", "string | /* e */ number"), + ("Array", "Array"), + ("{ /* x */ a: 1 }", "{ /* x */ a: 1; }"), + ("{ a: 1 /* y */ }", "{ a: 1; }"), + ("// line\n string", "string"), + ("/** doc */ string", "string"), + ("{ /** doc */ a: 1 }", "{ /** doc */ a: 1; }"), + ("(/* p */ x: string) => void", "(/* p */ x: string) => void"), + ("Signal & { a: 1 }", "i0.Signal & { a: 1; }"), + ("keyof Signal", "keyof i0.Signal"), + ("unique symbol", "unique symbol"), + ("{ a: 1; /* z */ b: 2 }", "{ a: 1; /* z */ b: 2; }"), + ("{ a: 1; // z\n b: 2 }", "{ a: 1; // z\n b: 2; }"), + ("string | // z\n number", "string | // z\n number"), + ("/* a */ /* b */ string | number", "/* a */ /* b */ string | number"), + ("Array", "Array"), + ("{ a: /* x */ 1 }", "{ a: 1; }"), + ("{ a: /* x */ Signal }", "{ a: i0.Signal; }"), + ("{ a: /* x */ Box }", "{ a: Box; }"), + ("(x: string, /* y */ y: number) => void", "(x: string, /* y */ y: number) => void"), + ("(x: string) => /* r */ void", "(x: string) => void"), + ("/* a */ (string)", "(string)"), + ("(/* a */ string)", "(string)"), + ("{ /* x */ }", "{}"), + ("[/* a */ string]", "[/* a */ string]"), + ("[string /* a */, number]", "[string, number]"), + ("keyof /* k */ Box", "keyof Box"), + ("/* a */\n string | number", "/* a */ string | number"), + ("{\n // line\n a: 1;\n b: 2\n}", "{ a: 1; b: 2; }"), + ("Signal", "i0.Signal"), + ("/* c */ Signal | null", "/* c */ i0.Signal | null"), + ("/* c */ Array", "Array"), + ("/* c */ string[]", "string[]"), + ("/* c */ { a: 1 }", "{ a: 1; }"), + ("/* c */ (x: string) => void", "(x: string) => void"), + ("/* c */ 'lit' | 1", "/* c */ \"lit\" | 1"), + ("/* c */ Box | null", "/* c */ Box | null"), + ("/* c */ Box", "Box"), + ("string[] /* c */ | number", "string[] | number"), + ("string /* d */ & number", "string & number"), + ("(string /* d */)", "(string)"), + ("{ a: 1 /* y */; b: 2 }", "{ a: 1; b: 2; }"), + ("{ a: 1; /* y */ }", "{ a: 1; }"), + ( + "0XABn | 0b101n | 0o17n | 1_000n | 0x1_Fn | 10n | 0Xabn", + "0xabn | 5n | 15n | 1000n | 0x1fn | 10n | 0xabn", + ), + ("{ 0x10(): void; 1.50?: 1; get 1e3(): 1 }", "{ 16(): void; 1.5?: 1; get 1000(): 1; }"), + ("{ m(): this is Signal }", "{ m(): this is i0.Signal; }"), + ("{ m(): asserts this }", "{ m(): asserts this; }"), + ("[...rest: Signal[]]", "[...rest: i0.Signal[]]"), + ("[a?: Signal]", "[a?: i0.Signal]"), + ("?string", "?string"), + ("string?", "?string"), + ( + "TT extends 'a' ? TT extends 'b' ? 1 : 2 : 3", + "TT extends \"a\" ? TT extends \"b\" ? 1 : 2 : 3", + ), + ("{ [K in TT]?: Signal }", "{ [K in TT]?: i0.Signal; }"), + ("(x: string) => (y: number) => void", "(x: string) => (y: number) => void"), + ("() => void", "() => void"), + ("new () => Box", "new () => Box"), + ("Signal>", "i0.Signal>"), + ("Box[][]", "Box[][]"), + ("keyof typeof val", "keyof typeof val"), + ("Array", "Array"), + ("(/* x */ string | number)[]", "(/* x */ string | number)[]"), + ("{ a: /* x */ string | number }", "{ a: /* x */ string | number; }"), + ("[/* x */ a: string]", "[/* x */ a: string]"), + ("() => void", "() => void"), + ("() => void", "() => void"), + ("{ m(/* x */ a: string): void }", "{ m(/* x */ a: string): void; }"), + ("({ /* x */ w }: { w: 1 }) => void", "({ /* x */ w }: { w: 1; }) => void"), + ("([/* x */ a]: [1]) => void", "([/* x */ a]: [1]) => void"), + ("new (/* x */ x: string) => Box", "new (/* x */ x: string) => Box"), + ("[/* a */ ...string[]]", "[/* a */ ...string[]]"), + ("/* x */ | 'a' | 'b'", "\"a\" | \"b\""), + ("| /* x */ 'a' | 'b'", "/* x */ \"a\" | \"b\""), + ("{ a: 1, /* x */ b: 2 }", "{ a: 1; /* x */ b: 2; }"), + ("{ a: 1 /* x */ ; b: 2 }", "{ a: 1; b: 2; }"), + ("{ a: 1; /* x */\n b: 2 }", "{ a: 1; /* x */ b: 2; }"), + ("string |\n /* x */ number", "string | number"), + ("string | /* x */\n number", "string | /* x */ number"), + ("Signal", "i0.Signal"), + ("{ /* x */ [k: string]: 1 }", "{ /* x */ [k: string]: 1; }"), + ("{ /* x */ (): void }", "{ /* x */ (): void; }"), + ("{ /* x */ [K in TT]: 1 }", "{ [K in TT]: 1; }"), + ("(/* a */ /* b */ x: string) => void", "(/* a */ /* b */ x: string) => void"), + ("string | /* a */ /* b */ number", "string | /* a */ /* b */ number"), + ("(/* x */ this: Box) => void", "(/* x */ this: Box) => void"), + ("{ /* x */ get g(): 1 }", "{ /* x */ get g(): 1; }"), + ("/** r */\n string | number", "/** r */ string | number"), + ("/* x */ /** j */ string | number", "/* x */ /** j */ string | number"), + ("string /* a */ | /* b */ number", "string | /* b */ number"), + ("!string", "!string"), + ("string!", "!string"), + ("?", "?"), + ("S", "i0.Signal"), + ("ng.Signal", "i0.Signal"), + ("TemplateRef", "i0.TemplateRef"), + ("WritableSignal", "i0.WritableSignal"), + ("typeof booleanAttribute", "typeof booleanAttribute"), + ("typeof oth", "typeof oth"), + ("Color", "Color"), + ("CE", "CE"), + ("Gen['v']", "Gen[\"v\"]"), + ("Parameters[0]", "Parameters[0]"), + ("{a: string\n b: number}", "{ a: string; b: number; }"), + ("\n | 'a'\n | 'b'", "\"a\" | \"b\""), + ("'q\"u\\'o\\\\té\\n😀'", "\"q\\\"u'o\\\\t\\u00E9\\n\\uD83D\\uDE00\""), + ("0x1F | 1e3 | 1_000 | .5 | -0 | 10n | -10n", "31 | 1000 | 1000 | 0.5 | -0 | 10n | -10n"), + ("1.50 | 0.1e-7 | 1e21 | 0b101 | 0o17", "1.5 | 1e-8 | 1e+21 | 5 | 15"), + ( + "'tab\\there' | \"a'b\" | 'uni\\u{1F600}' | '\\x41' | '\\0'", + "\"tab\\there\" | \"a'b\" | \"uni\\uD83D\\uDE00\" | \"A\" | \"\\0\"", + ), + ("{\"quoted-key\": 1; 'single': 2; 3: 4}", "{ \"quoted-key\": 1; \"single\": 2; 3: 4; }"), + ("{a: 1,}", "{ a: 1; }"), + ("Uppercase", "Uppercase"), + ( + "Date | HTMLElement | Map>", + "Date | HTMLElement | Map>", + ), + ("{ a: 1 /* x */\n b: 2 }", "{ a: 1; /* x */ b: 2; }"), + ("{ a: 1 // x\n b: 2 }", "{ a: 1; // x\n b: 2; }"), + ("Array", "Array"), + ("- 0x10 | -0b11n", "-16 | -3n"), + ("{ [/* x */ k: string]: 1 }", "{ [/* x */ k: string]: 1; }"), + ("(x: string) => (/* y */ y: number) => void", "(x: string) => (/* y */ y: number) => void"), + ("`${ /* x */ string}`", "`${string}`"), + ("{ 'a': 1 }\n", "{ \"a\": 1; }"), + ("string\n | number", "string | number"), + ("(x: string, ) => void", "(x: string) => void"), + ("() => void", "() => void"), + ("typeof val . a", "typeof val.a"), + ("TT extends 'a' ? 1 : 2", "TT extends \"a\" ? 1 : 2"), + ("`x-${TT}`", "`x-${TT}`"), + ( + "(a: string, b?: number, ...c: boolean[]) => void", + "(a: string, b?: number, ...c: boolean[]) => void", + ), + ("new (x: number) => Box", "new (x: number) => Box"), + ("abstract new () => Box", "abstract new () => Box"), + ("[a: string, b?: number, ...c: boolean[]]", "[a: string, b?: number, ...c: boolean[]]"), + ("[string?, ...number[]]", "[string?, ...number[]]"), + ("readonly [string, number]", "readonly [string, number]"), + ("ReadonlyArray", "ReadonlyArray"), + ("{[k: string]: number}", "{ [k: string]: number; }"), + ( + "{(x: number): string; new (): Box; m(): void; readonly r: 1; 'q-x': 2; [Symbol.iterator]: 3}", + "{ (x: number): string; new (): Box; m(): void; readonly r: 1; \"q-x\": 2; [Symbol.iterator]: 3; }", + ), + ("((string))", "((string))"), + ("(string | number)[][]", "(string | number)[][]"), + ("(() => void) | null", "(() => void) | null"), + ("keyof TT[]", "keyof TT[]"), + ("`${number}px`", "`${number}px`"), + ("`a\\`b\\n${string}`", "`a\\`b\\n${string}`"), + ("{a: string, b: number}", "{ a: string; b: number; }"), + ("{get x(): string; set x(v: string)}", "{ get x(): string; set x(v: string); }"), + ("(x: unknown) => x is string", "(x: unknown) => x is string"), + ("Array", "Array"), + ("{a:{b:{c:1}}}", "{ a: { b: { c: 1; }; }; }"), + ( + "(x: {a: 1}, [y, z]: [1, 2], {w}: {w: 3}) => void", + "(x: { a: 1; }, [y, z]: [1, 2], { w }: { w: 3; }) => void", + ), + ("string = 'x'", "string"), + ("{ get x(): Signal }", "{ get x(): i0.Signal; }"), + ("{ 'a b': Signal }", "{ \"a b\": i0.Signal; }"), + ("[a: Signal]", "[a: i0.Signal]"), + ("(...a: Signal[]) => void", "(...a: i0.Signal[]) => void"), + ("(a?: Signal) => void", "(a?: i0.Signal) => void"), +]; + +/// Types naming `./other`, and what ngtsc writes for them. +const OTHER_MODULE: &[(&str, &str)] = &[ + ("{[K in 'a']: Other}", "{ [K in \"a\"]: i1.Other; }"), + ("{ m(): Other }", "{ m(): i1.Other; }"), + ("{ [k: number]: Other }", "{ [k: number]: i1.Other; }"), + ("{ set x(v: Other) }", "{ set x(v: i1.Other); }"), + ("{ get x(): Other }", "{ get x(): i1.Other; }"), + ("(x: unknown) => x is Other", "(x: unknown) => x is i1.Other"), + ("(x: unknown) => asserts x is Other", "(x: unknown) => asserts x is i1.Other"), + ("() => void", "() => void"), + ("(this: Other) => void", "(this: i1.Other) => void"), + ("new (x: Other) => Box", "new (x: i1.Other) => Box"), + ("typeof gen", "typeof gen"), + ("[a: Signal, b?: Other]", "[a: i0.Signal, b?: i1.Other]"), + ("(Signal | Other)[]", "(i0.Signal | i1.Other)[]"), + ("oth.Other", "i1.Other"), + ("ng.ElementRef>", "i0.ElementRef>"), + ("ToSignalOptions", "i1.ToSignalOptions"), + ("Other", "i1.Other"), + ("{[K in TT]: Other}", "{ [K in TT]: i1.Other; }"), +]; + +/// The member name for inputs whose class property is a string key. ngtsc +/// quotes `ngAcceptInputType_` only when the name has a `-` or `.` +/// (Angular's `isUnsafeObjectKey`), escaped the way TypeScript prints a +/// string literal; any other name is written as is, even when that doesn't +/// parse. Every expected line is what ngtsc 22.1.7 wrote for this source. +#[test] +fn member_name_is_quoted_like_ngtsc() { + let source = r#"import {Directive, Input} from '@angular/core'; +function tr(v: string | number): string { return String(v); } +@Directive({selector: '[m]'}) +export class M { + @Input({transform: tr}) 'a"b': any; + @Input({transform: tr}) 'c\\d': any; + @Input({transform: tr}) 'e\nf': any; + @Input({transform: tr}) "i'j": any; + @Input({transform: tr}) 'mé': any; + @Input({transform: tr}) 'k-l': any; + @Input({transform: tr}) 'a-"b': any; + @Input({transform: tr}) 'c.\\d': any; + @Input({transform: tr}) 'e-\nf': any; + @Input({transform: tr}) 'k-é': any; + @Input({transform: tr}) 'x-😀\u0001\u000b\0': any; + @Input({transform: tr}) 'a.b': any; + @Input({transform: tr}) "i-'j": any; + @Input({transform: tr}) norm: any; +} +@Directive({selector: '[a]', inputs: [{name: 'q-"r', transform: tr}, {name: 's"t', transform: tr}]}) +export class A { 'q-"r': any; 's"t': any; } +"#; + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + "m.ts", + source, + Some(&TransformOptions::default()), + None, + ); + let members = |class: &str| { + let decl = result.dts_declarations.iter().find(|d| d.class_name == class).unwrap(); + decl.members + .split("\nstatic ") + .filter(|m| { + m.starts_with("ngAcceptInputType_") || m.starts_with("\"ngAcceptInputType_") + }) + .map(|m| format!("static {m}")) + .collect::>() + }; + let t = ": string | number;"; + let expect = + |names: &[&str]| names.iter().map(|n| format!("static {n}{t}")).collect::>(); + assert_eq!( + members("M"), + expect(&[ + "ngAcceptInputType_a\"b", + "ngAcceptInputType_c\\d", + "ngAcceptInputType_e\nf", + "ngAcceptInputType_i'j", + "ngAcceptInputType_mé", + "\"ngAcceptInputType_k-l\"", + "\"ngAcceptInputType_a-\\\"b\"", + "\"ngAcceptInputType_c.\\\\d\"", + "\"ngAcceptInputType_e-\\nf\"", + "\"ngAcceptInputType_k-\\u00E9\"", + "\"ngAcceptInputType_x-\\uD83D\\uDE00\\u0001\\v\\0\"", + "\"ngAcceptInputType_a.b\"", + "\"ngAcceptInputType_i-'j\"", + "ngAcceptInputType_norm", + ]) + ); + assert_eq!(members("A"), expect(&["\"ngAcceptInputType_q-\\\"r\"", "ngAcceptInputType_s\"t"])); +} 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 acfbb4dee..7c64a5630 100644 --- a/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json +++ b/crates/oxc_angular_compiler/tests/fixtures/decorator_metadata_ngtsc.json @@ -286,6 +286,58 @@ "dtsImports": [], "diagnosticSpans": [] }, + { + "name": "ngtsc_spec: should *not* generate a validator fn for attribute and property bindings when *not* on