Skip to content

Commit 3d03819

Browse files
Brooooooklynclaude
andcommitted
fix(dts): resolve import-equals aliases in computed property names
A computed name in a transform's parameter type (`{ [Core.ɵSIGNAL]: string }`) skipped the alias resolution type names get, so an alias of `@angular/core` (`import Core = require('@angular/core')`, `import C = ng`, `import S = Core.ɵSIGNAL`, or one declared in a namespace) was written as is, another module's alias didn't make the type `unknown`, and a global's alias wasn't written as its target. Computed names now resolve aliases the way type names do: `i0.ɵSIGNAL`, `unknown`, and `G.k`. ngtsc 22.1.7 copies all of these as written, which doesn't resolve in its `.d.ts`. An alias of a name the file declares stays as written, like ngtsc. The snapshot's `scope-typeOnly-*` probes (added earlier in the stack, before `.d.ts` transform types) now also compare ngtsc's `ngAcceptInputType_*` types, including `typeof name` for a helper's parameter used as a type. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent e8ca371 commit 3d03819

3 files changed

Lines changed: 193 additions & 36 deletions

File tree

‎crates/oxc_angular_compiler/src/directive/dts_type.rs‎

Lines changed: 76 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ pub(crate) struct TypePrinter<'s, 'a> {
3232
pub other_module: bool,
3333
}
3434

35-
impl TypePrinter<'_, '_> {
35+
impl<'a> TypePrinter<'_, 'a> {
3636
/// `unknown` when the type has a form ngtsc can't emit either (an
3737
/// `import('...')` type) or that isn't valid in a type annotation.
3838
pub(crate) fn print(&mut self, ty: &TSType<'_>) -> String {
@@ -211,26 +211,42 @@ impl TypePrinter<'_, '_> {
211211
fn type_name(&mut self, name: &TSTypeName<'_>) -> Option<String> {
212212
let mut parts = std::vec::Vec::new();
213213
entity_parts(name, &mut parts)?;
214+
Some(match self.resolve_aliases(parts)? {
215+
Resolved::Core(rest) => format!("i0{rest}"),
216+
Resolved::OtherModule(parts) => {
217+
self.other_module = true;
218+
parts.join(".")
219+
}
220+
Resolved::Name(parts, _) => self.resolved_name(&parts),
221+
})
222+
}
223+
224+
/// `parts` (a name, head first) with the import-equals aliases it starts
225+
/// with replaced by their targets, until its head isn't one.
226+
fn resolve_aliases<'p>(&self, mut parts: std::vec::Vec<&'p str>) -> Option<Resolved<'p>>
227+
where
228+
'a: 'p,
229+
{
230+
let mut aliased = false;
214231
// Aliases can name aliases; a cycle is a TypeScript error.
215232
for _ in 0..16 {
216233
let (len, target) = match self.scope.alias(parts[0]) {
217234
Some(target) => (1, target),
218235
None => match self.scope.namespace_alias(&parts) {
219236
Some(found) => found,
220-
None => return Some(self.resolved_name(&parts)),
237+
None => return Some(Resolved::Name(parts, aliased)),
221238
},
222239
};
240+
aliased = true;
223241
match target {
224242
// `import Core = require('@angular/core')`: `Core.X` is
225243
// `i0.X`, like a namespace import's member.
226244
AliasTarget::Module("@angular/core") => {
227-
let rest: String = parts[len..].iter().map(|m| format!(".{m}")).collect();
228-
return Some(format!("i0{rest}"));
229-
}
230-
AliasTarget::Module(_) => {
231-
self.other_module = true;
232-
return Some(parts.join("."));
245+
return Some(Resolved::Core(
246+
parts[len..].iter().map(|m| format!(".{m}")).collect(),
247+
));
233248
}
249+
AliasTarget::Module(_) => return Some(Resolved::OtherModule(parts)),
234250
AliasTarget::Entity(target) => {
235251
parts.splice(0..len, target);
236252
}
@@ -413,24 +429,53 @@ impl TypePrinter<'_, '_> {
413429
/// `@angular/core` value becomes `i0.token`, another module's makes the
414430
/// type `unknown`, and a local or global one stays as written (ngtsc copies
415431
/// the expression, which doesn't resolve for an import).
432+
///
433+
/// Through an import-equals alias too, although ngtsc copies those as
434+
/// written: an alias of `@angular/core` gives `i0.token` and one of another
435+
/// module `unknown`, where ngtsc's name doesn't resolve in the `.d.ts`. An
436+
/// alias of a global is written as its target (`[Symbol.iterator]` for
437+
/// `import S = Symbol`), and one of a name the file declares stays as
438+
/// written, like ngtsc.
416439
fn key(&mut self, key: &PropertyKey<'_>, computed: bool) -> Option<String> {
417440
let text = match key {
418441
PropertyKey::StaticIdentifier(id) => id.name.to_string(),
419442
PropertyKey::StringLiteral(s) => quote_literal(s),
420443
PropertyKey::NumericLiteral(n) => format_number_like_js(n.value),
421444
PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span),
422-
PropertyKey::Identifier(id) => self.value_name(&id.name, ""),
445+
PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?,
423446
PropertyKey::StaticMemberExpression(m) => {
424-
let chain = member_chain(&m.object, &m.property.name)?;
425-
let (head, rest) = chain.split_once('.').unwrap_or((&chain, ""));
426-
let rest = if rest.is_empty() { String::new() } else { format!(".{rest}") };
427-
self.value_name(head, &rest)
447+
let mut parts = std::vec::Vec::new();
448+
member_parts(&m.object, &mut parts)?;
449+
parts.push(m.property.name.as_str());
450+
self.computed_name(parts)?
428451
}
429452
_ => return None,
430453
};
431454
Some(if computed { format!("[{text}]") } else { text })
432455
}
433456

457+
/// A computed property name `a.b.c` (`parts`, head first), as [`Self::key`]
458+
/// writes it.
459+
fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option<String> {
460+
let written = parts.join(".");
461+
Some(match self.resolve_aliases(parts)? {
462+
Resolved::Core(rest) => format!("i0{rest}"),
463+
Resolved::OtherModule(_) => {
464+
self.other_module = true;
465+
written
466+
}
467+
Resolved::Name(parts, aliased) => {
468+
let (head, members) = (parts[0], &parts[1..]);
469+
if aliased && self.scope.import(head).is_none() && self.scope.declares(head) {
470+
written
471+
} else {
472+
let rest: String = members.iter().map(|m| format!(".{m}")).collect();
473+
self.value_name(head, &rest)
474+
}
475+
}
476+
})
477+
}
478+
434479
fn params(
435480
&mut self,
436481
this: Option<&TSThisParameter<'_>>,
@@ -592,15 +637,27 @@ fn entity_parts<'n>(name: &'n TSTypeName<'_>, out: &mut Vec<&'n str>) -> Option<
592637
Some(())
593638
}
594639

595-
fn member_chain(object: &Expression<'_>, property: &str) -> Option<String> {
596-
let object = match object {
597-
Expression::Identifier(id) => id.name.to_string(),
640+
/// The parts of a computed name's object, `a.b` in `[a.b.c]`, head first.
641+
fn member_parts<'n>(object: &'n Expression<'_>, out: &mut Vec<&'n str>) -> Option<()> {
642+
match object {
643+
Expression::Identifier(id) => out.push(id.name.as_str()),
598644
Expression::StaticMemberExpression(m) if !m.optional => {
599-
member_chain(&m.object, &m.property.name)?
645+
member_parts(&m.object, out)?;
646+
out.push(m.property.name.as_str());
600647
}
601648
_ => return None,
602-
};
603-
Some(format!("{object}.{property}"))
649+
}
650+
Some(())
651+
}
652+
653+
/// A name with its import-equals aliases replaced by their targets.
654+
enum Resolved<'p> {
655+
/// An alias of `@angular/core`: the `.member`s after it.
656+
Core(String),
657+
/// An alias of another module: the name as written.
658+
OtherModule(std::vec::Vec<&'p str>),
659+
/// Any other name, head first, and whether an alias was replaced.
660+
Name(std::vec::Vec<&'p str>, bool),
604661
}
605662

606663
fn is_line_break(c: char) -> bool {

‎crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,89 @@ fn computed_names_from_imports_follow_type_name_rules() {
503503
]);
504504
}
505505

506+
/// A computed property name through an import-equals alias (top level, in a
507+
/// namespace, or of an alias) resolves it the way a type name does. ngtsc
508+
/// 22.1.7 copies every one of these as written. An alias of a name the file
509+
/// declares stays as written, as ngtsc wrote it (the last group). The rest
510+
/// follow oxc's rules for imports and globals, since type-checking ngtsc's
511+
/// `.d.ts` reports "Cannot find name" for each (except `Ex`, which is
512+
/// exported): an alias of `@angular/core` gives `i0.X`, one of another module
513+
/// makes the type `unknown`, and one of a global (`G`, declared in a separate
514+
/// `.d.ts` for ngtsc) is written as its target.
515+
#[test]
516+
fn computed_names_through_import_equals_aliases() {
517+
let cases: [(&str, &[&str], &[&str]); 4] = [
518+
(
519+
"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",
520+
&[
521+
"{ [Core.ɵSIGNAL]: string }",
522+
"{ [Core.ɵSIGNAL](): void }",
523+
"{ [S]: string }",
524+
"{ [C2.ɵSIGNAL]: string }",
525+
"{ [Ex.ɵSIGNAL]: string }",
526+
"{ [C.ɵSIGNAL]: string }",
527+
"{ [S2]: string }",
528+
"{ [W.C3.ɵSIGNAL]: string }",
529+
"{ [W.S3]: string }",
530+
],
531+
&[
532+
"{ [i0.ɵSIGNAL]: string; }",
533+
"{ [i0.ɵSIGNAL](): void; }",
534+
"{ [i0.ɵSIGNAL]: string; }",
535+
"{ [i0.ɵSIGNAL]: string; }",
536+
"{ [i0.ɵSIGNAL]: string; }",
537+
"{ [i0.ɵSIGNAL]: string; }",
538+
"{ [i0.ɵSIGNAL]: string; }",
539+
"{ [i0.ɵSIGNAL]: string; }",
540+
"{ [i0.ɵSIGNAL]: string; }",
541+
],
542+
),
543+
(
544+
"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",
545+
&[
546+
"{ [R.token]: string }",
547+
"{ [R.Inner.k]: string }",
548+
"{ [O.token]: string }",
549+
"{ [T]: string }",
550+
"{ [I.k]: string }",
551+
"{ [W.O2.token]: string }",
552+
],
553+
&["unknown", "unknown", "unknown", "unknown", "unknown", "unknown"],
554+
),
555+
(
556+
"import GA = G;\nimport GK = G.k;\nimport GI = G.In;\nnamespace W { export import GA2 = G; }\n",
557+
&[
558+
"{ [GA.k]: string }",
559+
"{ [GK]: string }",
560+
"{ [GI.j]: string }",
561+
"{ [W.GA2.k]: string }",
562+
],
563+
&[
564+
"{ [G.k]: string; }",
565+
"{ [G.k]: string; }",
566+
"{ [G.In.j]: string; }",
567+
"{ [G.k]: string; }",
568+
],
569+
),
570+
(
571+
"namespace NS { export const k = 'nk'; }\nimport A = NS;\nimport K = NS.k;\nnamespace W { export import A2 = NS; }\n",
572+
&["{ [A.k]: string }", "{ [K]: string }", "{ [W.A2.k]: string }"],
573+
&["{ [A.k]: string; }", "{ [K]: string; }", "{ [W.A2.k]: string; }"],
574+
),
575+
];
576+
for (imports, types, expected) in cases {
577+
let source = format!(
578+
"import {{Directive, Input}} from '@angular/core';
579+
{imports}@Directive({{selector: '[d]'}})
580+
export class Dir {{
581+
{}}}
582+
",
583+
members_typed(types)
584+
);
585+
assert_eq!(accept_members(&source), expect_members(expected), "{imports}");
586+
}
587+
}
588+
506589
/// An overloaded static method is typed from its first declaration, as an
507590
/// overloaded function is (ngtsc 22.1.7 writes `string` here). The snapshot
508591
/// can't hold this case: ngtsc compiles the transform to the bare method name

0 commit comments

Comments
 (0)