diff --git a/crates/oxc_angular_compiler/src/class_metadata/builders.rs b/crates/oxc_angular_compiler/src/class_metadata/builders.rs index c397ce648..fd0f8bf40 100644 --- a/crates/oxc_angular_compiler/src/class_metadata/builders.rs +++ b/crates/oxc_angular_compiler/src/class_metadata/builders.rs @@ -471,7 +471,7 @@ pub fn build_ctor_params_metadata_in<'a>( import_map, &class_type_params, local_decls, - const_enums_visible, + const_enums_visible.then_some(consts).flatten(), ) .unwrap_or_else(|| { OutputExpression::Literal(Box::new_in( @@ -968,9 +968,11 @@ fn string_literal<'a>(allocator: &'a Allocator, value: Ident<'a>) -> OutputExpre /// `const enum`s follow full and partial compilation instead, the modes the /// Angular CLI and ng-packagr run: there `typeToValue` gives /// `noValueDeclaration` for a `ConstEnum` symbol, so `type:` is `undefined`. -/// That holds for one declared in the file and for a named import the -/// cross-file analysis found to be one ([`crate::ImportInfo::is_const_enum`]), -/// when `const_enums_visible` says the class sees the file-scope names. +/// That holds for one declared in the file and for an import the cross-file +/// analysis followed to one ([`crate::ImportInfo::is_const_enum`], +/// [`StringConsts::is_imported_const_enum`]). `const_enum_scope` holds the +/// file's consts when the class sees the file-scope names (`None` for a +/// class nested in a function or block). fn build_param_type_expression<'a>( allocator: &'a Allocator, param: &FormalParameter<'a>, @@ -978,8 +980,9 @@ fn build_param_type_expression<'a>( import_map: &ImportMap<'a>, type_params: &FxHashSet>, local_decls: Option<&LocalDecls<'a>>, - const_enums_visible: bool, + const_enum_scope: Option<&StringConsts<'a>>, ) -> Option> { + let const_enums_visible = const_enum_scope.is_some(); // Upstream's getConstructorParameters first narrows `T | null` to `T` // (typescript.ts); `typeToValue` then requires a bare `TypeReferenceNode` // — `(X)`, `X | undefined`, `X | Y`, `typeof`, `this`, primitives are @@ -1009,9 +1012,13 @@ fn build_param_type_expression<'a>( if import_info.is_type_only || path.len() == 1 && !import_info.is_named_import { return None; } - // An imported `const enum` (`import { KeyCodes }`): no value in full - // or partial `typeToValue`, so no `i1` import either. - if const_enums_visible && path.len() == 1 && import_info.is_const_enum { + // An imported `const enum` (`import { KeyCodes }`, `k.KeyCodes`, + // `NS.Inner`): no value in full or partial `typeToValue`, so no `i1` + // import either. An enum member (`k.KeyCodes.A`) keeps its reference. + if let Some(consts) = const_enum_scope + && (path.len() == 1 && import_info.is_const_enum + || consts.is_imported_const_enum(&path)) + { return None; } // `import Foo from 'm'`: upstream emits the local name itself (LOCAL @@ -1173,6 +1180,31 @@ pub fn type_param_names_of<'a>(class: &Class<'a>) -> FxHashSet> { .unwrap_or_default() } +/// Whether a namespace body holds only types: interfaces, type aliases, +/// local `import X = A.B` aliases and namespaces like it (TypeScript's +/// `NonInstantiated` module state). Anything else counts as instantiated, +/// a nested `const enum` and an empty statement too. +fn declares_only_types(body: &TSNamespaceDeclarationBody<'_>) -> bool { + match body { + TSNamespaceDeclarationBody::TSModuleBlock(block) => { + block.body.iter().all(|stmt| match stmt { + Statement::TSInterfaceDeclaration(_) + | Statement::TSTypeAliasDeclaration(_) + | Statement::TSImportEqualsDeclaration(_) => true, + Statement::TSNamespaceDeclaration(ns) => declares_only_types(&ns.body), + Statement::ExportDeclaration(export) => match &export.declaration { + Declaration::TSInterfaceDeclaration(_) + | Declaration::TSTypeAliasDeclaration(_) => true, + Declaration::TSNamespaceDeclaration(ns) => declares_only_types(&ns.body), + _ => false, + }, + _ => false, + }) + } + TSNamespaceDeclarationBody::TSNamespaceDeclaration(ns) => declares_only_types(&ns.body), + } +} + /// Names declared in the file, nested under their namespaces. /// /// This models the local-compilation `typeToValue` (the mode the e2e @@ -1202,6 +1234,10 @@ pub(crate) struct LocalDecls<'a> { /// Names declared by a `const enum` (also in `value_decls`). See /// [`Self::is_const_enum`]. const_enums: FxHashSet<&'a str>, + /// Names of namespaces that hold more than types (see + /// [`declares_only_types`]). A `const enum` merged with one of them is a + /// TypeScript error, so [`Self::is_const_enum`] keeps today's output. + instantiated_namespaces: FxHashSet<&'a str>, } impl<'a> LocalDecls<'a> { @@ -1455,6 +1491,9 @@ impl<'a> LocalDecls<'a> { decl: &'a oxc_ast::ast::TSNamespaceDeclaration<'a>, globals: &mut std::vec::Vec<&'a Statement<'a>>, ) { + if !declares_only_types(&decl.body) { + self.instantiated_namespaces.insert(decl.id.name.as_str()); + } self.namespaces.entry(decl.id.name.as_str()).or_default().merge(Self::from_namespace_body( &decl.body, decl.declare, @@ -1476,6 +1515,8 @@ impl<'a> LocalDecls<'a> { // `NS.E` reads an exported member only: a const enum the block // keeps to itself must not hide an exported `E` that another // `namespace NS` block declares. + // The same goes for a namespace the block keeps to itself: it + // doesn't merge with an exported `const enum` of another block. if !ambient { decls.const_enums.retain(|name| { block.body.iter().any(|stmt| { @@ -1484,12 +1525,22 @@ impl<'a> LocalDecls<'a> { Declaration::TSEnumDeclaration(e) if e.id.name == *name)) }) }); + decls.instantiated_namespaces.retain(|name| { + block.body.iter().any(|stmt| { + matches!(stmt, Statement::ExportDeclaration(export) + if matches!(&export.declaration, + Declaration::TSNamespaceDeclaration(ns) if ns.id.name == *name)) + }) + }); } decls } // `namespace Outer.Inner { ... }` nests by qualified name. TSNamespaceDeclarationBody::TSNamespaceDeclaration(nested) => { let mut decls = LocalDecls::default(); + if !declares_only_types(&nested.body) { + decls.instantiated_namespaces.insert(nested.id.name.as_str()); + } decls.namespaces.insert( nested.id.name.as_str(), Self::from_namespace_body(&nested.body, ambient || nested.declare, globals), @@ -1503,6 +1554,7 @@ impl<'a> LocalDecls<'a> { self.type_decls.extend(other.type_decls); self.value_decls.extend(other.value_decls); self.const_enums.extend(other.const_enums); + self.instantiated_namespaces.extend(other.instantiated_namespaces); for (name, nested) in other.namespaces { self.namespaces.entry(name).or_default().merge(nested); } @@ -1533,16 +1585,18 @@ impl<'a> LocalDecls<'a> { /// (`declare const enum` too). ngtsc's `typeToValue` has no value for /// it outside local compilation (`decl.flags & ts.SymbolFlags.ConstEnum` /// gives `noValueDeclaration`), so full and partial metadata write - /// `type: undefined`. A name a namespace, interface, type alias or - /// `declare global` block also declares is left out: one file can't be - /// sure what it merges with, so it keeps today's output. + /// `type: undefined`. That holds when it merges with namespaces that + /// only hold types too. A name an instantiated namespace, interface, + /// type alias or `declare global` block also declares is left out: that + /// is a TypeScript error, or one file can't be sure what it merges with, + /// so it keeps today's output. pub(crate) fn is_const_enum(&self, path: &[Ident<'_>]) -> bool { match path.split_first() { None => false, Some((name, [])) => { let name = name.as_str(); self.const_enums.contains(name) - && !self.namespaces.contains_key(name) + && !self.instantiated_namespaces.contains(name) && !self.type_decls.contains(name) && !self.global_names.contains(name) } diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs index fcc72a818..4bec69841 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -32,6 +32,7 @@ use rustc_hash::{FxHashMap, FxHashSet}; use super::defer_resolver::{ClassUse, decorated_class_selector}; use crate::directive::{ImportValueResolver, StringConsts}; +mod const_enums; mod ng_module_scope; /// `(file, exported_name, local_name, implicit_standalone)` -> how a @@ -106,9 +107,9 @@ struct Inner { this: Weak, /// Memoized NgModule scope reads (see [`ng_module_scope`]). scope: ng_module_scope::ScopeState, - /// File path -> the names it exports a `const enum` under, filled by - /// `analyze_file` (see [`const_enum_export_names`]). - const_enum_exports: RefCell>>, + /// File path -> what the `const enum` lookups read of it (see + /// [`const_enums`]). + enum_shapes: RefCell>>, } /// A file parsed once per analyzer: its program, and the consts the @@ -258,7 +259,7 @@ impl CrossFileAnalyzer { value_dependencies: RefCell::new(FxHashSet::default()), this: this.clone(), scope: ng_module_scope::ScopeState::default(), - const_enum_exports: RefCell::new(FxHashMap::default()), + enum_shapes: RefCell::new(FxHashMap::default()), }), } } @@ -276,20 +277,6 @@ impl CrossFileAnalyzer { self.inner.is_type_only_import(import_source, import_name, from_file) } - /// Whether `import_name`, imported from `import_source` in `from_file`, - /// is a `const enum` where it is declared, following `export { X } from` - /// and `export *` chains. `false` when unsure (packages, `.d.ts`-only - /// modules, unresolvable paths, `export * as ns`). Files read are - /// recorded in [`Self::take_value_dependencies`]. - pub(crate) fn is_const_enum_import( - &self, - import_source: &str, - import_name: &str, - from_file: &Path, - ) -> bool { - self.inner.is_const_enum_import(import_source, import_name, from_file) - } - /// Resolve the actual source file path for an import, tracing through barrel exports. /// /// Returns the relative path from `from_file` to the actual source file where @@ -502,68 +489,6 @@ impl Inner { self.export_value_at(&resolved, import_name, 0) } - /// See [`CrossFileAnalyzer::is_const_enum_import`]. - fn is_const_enum_import( - &self, - import_source: &str, - import_name: &str, - from_file: &Path, - ) -> bool { - let Some(parent) = from_file.parent() else { return false }; - let Some(resolved) = self.resolve_module_spec(parent, import_source) else { - return false; - }; - self.const_enum_at(&resolved, import_name, 0) - } - - /// Whether `export_name` of `file_path` is a `const enum`, direct or - /// through re-exports, like [`Self::export_value_at`] follows them. - fn const_enum_at(&self, file_path: &str, export_name: &str, depth: u16) -> bool { - // A package's sources aren't read, like `is_type_only_import`. - if depth > MAX_EXPORT_CHAIN - || Path::new(file_path).components().any(|c| c.as_os_str() == "node_modules") - { - return false; - } - self.value_dependencies.borrow_mut().insert(file_path.to_string()); - if !self.cache.borrow().contains_key(file_path) { - if self.analyzing.borrow().contains(file_path) { - return false; // Circular import - } - self.analyze_file(file_path); - } - let export_info = { - let cache = self.cache.borrow(); - cache.get(file_path).and_then(|exports| exports.get(export_name).cloned()) - }; - let Some(export_info) = - export_info.or_else(|| self.find_in_star_exports(file_path, export_name)) - else { - return false; - }; - match export_info.re_export_source { - // `export * as ns` names a namespace, not one declaration. - Some((_, original_name)) if original_name == "*" => false, - Some((source_module, original_name)) => { - let Some(resolved) = Path::new(file_path) - .parent() - .and_then(|parent| self.resolve_module_spec(parent, &source_module)) - else { - return false; - }; - self.const_enum_at(&resolved, &original_name, depth + 1) - } - None => { - !export_info.is_type_only - && self - .const_enum_exports - .borrow() - .get(file_path) - .is_some_and(|names| names.contains(export_name)) - } - } - } - fn standalone_directive_selector( &self, import_source: &str, @@ -1104,9 +1029,6 @@ impl Inner { let source_type = SourceType::from_path(file_path).unwrap_or_default(); let parser_ret = Parser::new(&allocator, &source, source_type).parse(); self.analyze_program(&parser_ret.program, &mut exports); - self.const_enum_exports - .borrow_mut() - .insert(file_path.to_string(), const_enum_export_names(&parser_ret.program)); self.cache.borrow_mut().insert(file_path.to_string(), exports); self.analyzing.borrow_mut().remove(file_path); @@ -1392,7 +1314,7 @@ impl Inner { self.parsed.borrow_mut().clear(); self.value_memo.borrow_mut().clear(); self.selector_memo.borrow_mut().clear(); - self.const_enum_exports.borrow_mut().clear(); + self.enum_shapes.borrow_mut().clear(); } /// Get the number of cached files. @@ -1401,47 +1323,6 @@ impl Inner { } } -/// The names `program` exports a top-level `const enum` under: -/// `export const enum E`, `export declare const enum E`, and `export { E }` / -/// `export { E as F }` of a top-level one. Empty when the file has a bare -/// `namespace`, `declare module` or `declare global` block: `analyze_statement` -/// lists their members as the file's own exports, so a name could mean either. -fn const_enum_export_names(program: &Program) -> FxHashSet { - let mut names = FxHashSet::default(); - let mut const_enums = FxHashSet::default(); - for stmt in &program.body { - match stmt { - Statement::TSNamespaceDeclaration(_) - | Statement::TSExternalModuleDeclaration(_) - | Statement::TSGlobalDeclaration(_) => return FxHashSet::default(), - Statement::TSEnumDeclaration(e) if e.r#const => { - const_enums.insert(e.id.name.as_str()); - } - Statement::ExportDeclaration(export) => { - if let Declaration::TSEnumDeclaration(e) = &export.declaration - && e.r#const - { - const_enums.insert(e.id.name.as_str()); - names.insert(e.id.name.to_string()); - } - } - _ => {} - } - } - for stmt in &program.body { - if let Statement::ExportNamedDeclaration(export) = stmt - && !export.export_kind.is_type() - { - for spec in &export.specifiers { - if !spec.export_kind.is_type() && const_enums.contains(spec.local.name().as_str()) { - names.insert(spec.exported.name().to_string()); - } - } - } - } - names -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision/const_enums.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision/const_enums.rs new file mode 100644 index 000000000..82a7c504a --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/const_enums.rs @@ -0,0 +1,452 @@ +//! Which imported types are `const enum`s, for the class metadata `type:` of +//! a constructor parameter (#623). ngtsc's full and partial `typeToValue` +//! write `undefined` when the symbol at the end of the type name is a +//! `ConstEnum`, wherever it is declared: a source file, a `.d.ts` file or a +//! package's typings, reached through named imports, namespace imports, +//! re-exports and namespaces (`k.NS.Inner`). +//! +//! Each file read is kept as an [`EnumShape`]: its imports, `const enum`s, +//! namespaces and exports. A type name is followed through them the way +//! TypeScript resolves its symbol. Anything that can't be followed gives +//! `false`, and the caller keeps today's output. +//! +//! The shapes of package files are also kept for the whole process (see +//! [`PACKAGE_SHAPES`]): each transform call has its own analyzer, and a +//! package's typings are large (`@angular/core` reads about 670 KB). + +use std::path::Path; +use std::sync::{Arc, LazyLock, Mutex, PoisonError}; +use std::time::SystemTime; + +use oxc_allocator::Allocator; +use oxc_ast::ast::{ + Declaration, ExportDefaultDeclarationKind, ImportDeclarationSpecifier, Program, Statement, + TSNamespaceDeclarationBody, +}; +use oxc_parser::Parser; +use oxc_span::SourceType; +use rustc_hash::{FxHashMap, FxHashSet}; + +use super::ng_module_scope::in_node_modules; +use super::{CrossFileAnalyzer, Inner, MAX_EXPORT_CHAIN}; + +/// The shapes of the package files (under `node_modules`) any analyzer has +/// read, by path, with the [`FileStamp`] they were read at. A file whose +/// stamp changed is read again. +static PACKAGE_SHAPES: LazyLock> = LazyLock::new(Mutex::default); + +type PackageShapes = FxHashMap)>; + +/// When a file was last changed, and its size. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileStamp { + modified: Option, + len: u64, +} + +/// Names in one scope: a file's top level, or the exported members of the +/// namespaces of one name. +type Scope = FxHashMap; + +/// What a name is bound to in a [`Scope`]. One name can be a `const enum` +/// and a namespace at once: TypeScript merges them. +#[derive(Debug, Default)] +struct Binding { + /// A `const enum` declares the name. + const_enum: bool, + /// The exported members of the namespaces declared under the name, + /// across all their blocks. + members: Option, + /// The import that binds the name (top level only). + import: Option, +} + +#[derive(Debug)] +enum Import { + /// `import { name as local }`, or `import local` for `"default"`. + Named { spec: String, name: String }, + /// `import * as local`. + Namespace { spec: String }, +} + +#[derive(Debug)] +enum Export { + /// `export const enum X`, `export { X as Y }`, `export default X`: the + /// top-level binding it names. + Local(String), + /// `export { name as Y } from 'spec'`, `export type` too. + From { spec: String, name: String }, + /// `export * as Y from 'spec'`. + Namespace { spec: String }, + /// Anything else (`export default class`, `export import X = ...`). + Other, +} + +/// What [`Inner::export_path`] needs to know about one file. +#[derive(Debug, Default)] +pub(super) struct EnumShape { + locals: Scope, + exports: FxHashMap, + /// The specifiers of `export * from`, in source order. + stars: Vec, + /// `export =`: the module's exports can't be read. + opaque: bool, +} + +impl CrossFileAnalyzer { + /// Whether `path`, read from the module `import_source` (as `from_file` + /// writes it), ends at a `const enum`. `path[0]` is the name the module + /// exports (`"default"` for a default import), and the rest walks into + /// namespaces: `k.NS.Inner` of `import * as k` is `["NS", "Inner"]`. + /// `false` when unsure. Files read are recorded in + /// [`Self::take_value_dependencies`]. + pub(crate) fn is_const_enum_path( + &self, + import_source: &str, + path: &[&str], + from_file: &Path, + ) -> bool { + let Some(file) = self.inner.resolve_types(&from_file.to_string_lossy(), import_source) + else { + return false; + }; + self.inner.export_path(&file, path, 0, &mut FxHashSet::default()) == Some(true) + } +} + +impl Inner { + /// Whether `path` ends at a `const enum`, when the module `file` exports + /// `path[0]`: `Some(false)` when unsure, `None` when it doesn't export + /// that name. `seen` holds the `(file, path)` pairs already looked at, so + /// a cycle or a second way to the same file ends. + fn export_path( + &self, + file: &str, + path: &[&str], + depth: u16, + seen: &mut FxHashSet<(String, String)>, + ) -> Option { + let (name, rest) = path.split_first()?; + if !seen.insert((file.to_string(), path.join("."))) { + return None; + } + if depth > MAX_EXPORT_CHAIN { + return Some(false); + } + let Some(shape) = self.enum_shape(file) else { return Some(false) }; + if shape.opaque { + return Some(false); + } + match shape.exports.get(*name) { + Some(Export::Local(local)) => { + Some(self.local_path(file, &shape, local, rest, depth, seen)) + } + Some(Export::From { spec, name }) => { + let path: Vec<&str> = + std::iter::once(name.as_str()).chain(rest.iter().copied()).collect(); + Some(self.module_path(file, spec, &path, depth, seen)) + } + Some(Export::Namespace { spec }) => { + Some(!rest.is_empty() && self.module_path(file, spec, rest, depth, seen)) + } + Some(Export::Other) => Some(false), + // `export *` leaves out `default`. + None if *name == "default" => None, + // Only a plain `export *` adds names: `export * as ns` adds one. + None => shape.stars.iter().find_map(|spec| match self.resolve_types(file, spec) { + Some(target) => self.export_path(&target, path, depth + 1, seen), + None => Some(false), + }), + } + } + + /// [`Self::export_path`] in the module `spec` names from `file`, which + /// has to export `path[0]`. + fn module_path( + &self, + file: &str, + spec: &str, + path: &[&str], + depth: u16, + seen: &mut FxHashSet<(String, String)>, + ) -> bool { + self.resolve_types(file, spec) + .and_then(|target| self.export_path(&target, path, depth + 1, seen)) + == Some(true) + } + + /// Whether the top-level binding `local` of `file`, then `rest`, ends at + /// a `const enum`. + fn local_path( + &self, + file: &str, + shape: &EnumShape, + local: &str, + rest: &[&str], + depth: u16, + seen: &mut FxHashSet<(String, String)>, + ) -> bool { + let Some(binding) = shape.locals.get(local) else { return false }; + match &binding.import { + Some(Import::Named { spec, name }) => { + let path: Vec<&str> = + std::iter::once(name.as_str()).chain(rest.iter().copied()).collect(); + self.module_path(file, spec, &path, depth, seen) + } + Some(Import::Namespace { spec }) => { + !rest.is_empty() && self.module_path(file, spec, rest, depth, seen) + } + None => binding.ends_at_const_enum(rest), + } + } + + /// The [`EnumShape`] of `file`, read on first use. `None` when it can't + /// be read or parsed. + fn enum_shape(&self, file: &str) -> Option> { + if let Some(shape) = self.enum_shapes.borrow().get(file) { + return Some(Arc::clone(shape)); + } + let shape = + if in_node_modules(file) { package_shape(file)? } else { Arc::new(read_shape(file)?) }; + self.enum_shapes.borrow_mut().insert(file.to_string(), Arc::clone(&shape)); + Some(shape) + } +} + +/// The [`EnumShape`] of the package file `file`, from [`PACKAGE_SHAPES`] +/// while the file is unchanged. +fn package_shape(file: &str) -> Option> { + let meta = std::fs::metadata(file).ok()?; + let stamp = FileStamp { modified: meta.modified().ok(), len: meta.len() }; + let cached = PACKAGE_SHAPES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(file) + .filter(|(read_at, _)| *read_at == stamp) + .map(|(_, shape)| Arc::clone(shape)); + if cached.is_some() { + return cached; + } + // Read without the lock: another thread may read the same file too. + let shape = Arc::new(read_shape(file)?); + PACKAGE_SHAPES + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(file.to_string(), (stamp, Arc::clone(&shape))); + Some(shape) +} + +/// Read and parse `file` into its [`EnumShape`]. +fn read_shape(file: &str) -> Option { + let source = std::fs::read_to_string(file).ok()?; + let allocator = Allocator::default(); + let source_type = SourceType::from_path(file).unwrap_or_default(); + let parsed = Parser::new(&allocator, &source, source_type).parse(); + if parsed.fatal_error { + return None; + } + Some(collect_enum_shape(&parsed.program, source_type.is_typescript_definition())) +} + +impl Binding { + /// Whether `path`, read from this binding's namespace members, ends at a + /// `const enum` (this binding itself for an empty path). + fn ends_at_const_enum(&self, path: &[&str]) -> bool { + match path.split_first() { + None => self.const_enum, + Some((name, rest)) => self + .members + .as_ref() + .and_then(|members| members.get(*name)) + .is_some_and(|member| member.ends_at_const_enum(rest)), + } + } + + fn merge_members(&mut self, members: Scope) { + let mine = self.members.get_or_insert_with(Scope::default); + for (name, binding) in members { + mine.entry(name).or_default().merge(binding); + } + } + + fn merge(&mut self, other: Binding) { + self.const_enum |= other.const_enum; + if let Some(members) = other.members { + self.merge_members(members); + } + if other.import.is_some() { + self.import = other.import; + } + } +} + +/// The [`EnumShape`] of `program`. `declaration_file` is whether it is a +/// `.d.ts` file: without `export {}`, `export ... from`, `export *`, +/// `export =` or `export default `, its top-level declarations are +/// exported even without `export` (TypeScript's `ExportContext`). +fn collect_enum_shape(program: &Program<'_>, declaration_file: bool) -> EnumShape { + let export_context = declaration_file + && !program.body.iter().any(|stmt| match stmt { + Statement::ExportNamedDeclaration(_) + | Statement::ExportFromDeclaration(_) + | Statement::ExportAllDeclaration(_) + | Statement::TSExportAssignment(_) => true, + Statement::ExportDefaultDeclaration(export) => export.declaration.is_expression(), + _ => false, + }); + let mut shape = EnumShape::default(); + for stmt in &program.body { + match stmt { + Statement::ImportDeclaration(import) => { + let Some(spec) = import.source.value.as_str() else { continue }; + for specifier in import.specifiers.iter().flatten() { + let (local, import) = match specifier { + ImportDeclarationSpecifier::ImportSpecifier(s) => ( + &s.local.name, + Import::Named { + spec: spec.to_string(), + name: s.imported.name().to_string(), + }, + ), + ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => ( + &s.local.name, + Import::Named { spec: spec.to_string(), name: "default".to_string() }, + ), + ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => { + (&s.local.name, Import::Namespace { spec: spec.to_string() }) + } + }; + shape.locals.entry(local.to_string()).or_default().import = Some(import); + } + } + Statement::ExportDeclaration(export) => { + declare(&mut shape, &export.declaration, declaration_file, true); + } + Statement::ExportNamedDeclaration(export) => { + for spec in &export.specifiers { + let local = spec.local.name().to_string(); + add_export(&mut shape, &spec.exported.name(), Export::Local(local)); + } + } + Statement::ExportFromDeclaration(export) => { + let Some(spec) = export.source.value.as_str() else { continue }; + for specifier in &export.specifiers { + let name = specifier.local.name().to_string(); + let how = Export::From { spec: spec.to_string(), name }; + add_export(&mut shape, &specifier.exported.name(), how); + } + } + Statement::ExportAllDeclaration(export) => { + let Some(spec) = export.source.value.as_str() else { continue }; + match &export.exported { + Some(name) => { + let how = Export::Namespace { spec: spec.to_string() }; + add_export(&mut shape, &name.name(), how); + } + None => shape.stars.push(spec.to_string()), + } + } + Statement::ExportDefaultDeclaration(export) => { + let how = match &export.declaration { + ExportDefaultDeclarationKind::Identifier(id) => { + Export::Local(id.name.to_string()) + } + _ => Export::Other, + }; + add_export(&mut shape, "default", how); + } + Statement::TSExportAssignment(_) => shape.opaque = true, + _ => { + if let Some(declaration) = stmt.as_declaration() { + declare(&mut shape, declaration, declaration_file, export_context); + } + } + } + } + shape +} + +fn add_export(shape: &mut EnumShape, name: &str, how: Export) { + shape.exports.entry(name.to_string()).or_insert(how); +} + +/// Add a top-level `declaration` to `shape`, and to its exports when +/// `exported`. `ambient` is whether the file is a `.d.ts` file. +fn declare(shape: &mut EnumShape, declaration: &Declaration<'_>, ambient: bool, exported: bool) { + let mut names: Vec = Vec::new(); + match declaration { + Declaration::TSEnumDeclaration(e) => { + names.push(e.id.name.to_string()); + if e.r#const { + shape.locals.entry(e.id.name.to_string()).or_default().const_enum = true; + } + } + Declaration::TSNamespaceDeclaration(ns) => { + names.push(ns.id.name.to_string()); + let members = namespace_members(&ns.body, ambient || ns.declare); + shape.locals.entry(ns.id.name.to_string()).or_default().merge_members(members); + } + Declaration::ClassDeclaration(class) => { + names.extend(class.id.iter().map(|id| id.name.to_string())); + } + Declaration::FunctionDeclaration(func) => { + names.extend(func.id.iter().map(|id| id.name.to_string())); + } + Declaration::VariableDeclaration(vars) => { + for var in &vars.declarations { + names.extend(var.id.get_binding_identifiers().iter().map(|id| id.name.to_string())); + } + } + Declaration::TSInterfaceDeclaration(d) => names.push(d.id.name.to_string()), + Declaration::TSTypeAliasDeclaration(d) => names.push(d.id.name.to_string()), + Declaration::TSImportEqualsDeclaration(d) => { + // `export import X = ...` isn't followed. + if exported { + add_export(shape, &d.id.name, Export::Other); + } + } + Declaration::TSExternalModuleDeclaration(_) | Declaration::TSGlobalDeclaration(_) => {} + } + if exported { + for name in names { + let how = Export::Local(name.clone()); + add_export(shape, &name, how); + } + } +} + +/// The members a namespace body exports: in an `ambient` (`declare`d) one, +/// every member. +fn namespace_members(body: &TSNamespaceDeclarationBody<'_>, ambient: bool) -> Scope { + let mut members = Scope::default(); + match body { + TSNamespaceDeclarationBody::TSModuleBlock(block) => { + for stmt in &block.body { + let declaration = match stmt { + Statement::ExportDeclaration(export) => &export.declaration, + _ if ambient => match stmt.as_declaration() { + Some(declaration) => declaration, + None => continue, + }, + _ => continue, + }; + match declaration { + Declaration::TSEnumDeclaration(e) if e.r#const => { + members.entry(e.id.name.to_string()).or_default().const_enum = true; + } + Declaration::TSNamespaceDeclaration(ns) => { + let nested = namespace_members(&ns.body, ambient || ns.declare); + members.entry(ns.id.name.to_string()).or_default().merge_members(nested); + } + _ => {} + } + } + } + // `namespace A.B { ... }`: `B` is a member of `A`. + TSNamespaceDeclarationBody::TSNamespaceDeclaration(ns) => { + let nested = namespace_members(&ns.body, ambient || ns.declare); + members.entry(ns.id.name.to_string()).or_default().merge_members(nested); + } + } + members +} diff --git a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs index 007613216..5c735ffa5 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/ng_module_scope.rs @@ -22,7 +22,7 @@ use std::rc::Rc; use oxc_ast::ast::{ ArrayExpressionElement, Class, ClassElement, Declaration, ExportDefaultDeclarationKind, Expression, ImportDeclarationSpecifier, ObjectPropertyKind, Program, PropertyKey, Statement, - TSLiteral, TSTupleElement, TSType, TSTypeName, + TSLiteral, TSTupleElement, TSType, TSTypeName, TSTypeOperatorOperator, }; use oxc_span::GetSpan; use rustc_hash::{FxHashMap, FxHashSet}; @@ -31,10 +31,12 @@ use super::{CrossFileAnalyzer, Inner, MAX_EXPORT_CHAIN}; use crate::component::metadata::{ComponentScope, ImportedName, ScopeEntry, ScopeKind}; use crate::directive::{Evaluator, StringConsts, find_angular_class_decorator}; use crate::ng_module::module_with_providers::{ - MwpInit, MwpModule, MwpRead, MwpType, module_with_providers_init, static_method_ng_module, - top_level_const_init, + Answer, MwpInit, MwpModule, MwpRead, MwpType, NamedClass, Question, evaluated, + module_with_providers_init, static_method_ng_module, top_level_const_init, +}; +use crate::ng_module::{ + EntryPlan, InjectorEntry, InjectorPlans, ResolvedRef, exports_plan, imports_plan, }; -use crate::ng_module::{InjectorDrops, InjectorEntry}; /// A top-level declaration: the file declaring it and its name there /// (`"default"` for an anonymous `export default class`). @@ -46,12 +48,28 @@ struct Decl { /// A reference to a class as some file writes it: `name`, `namespace.name`, /// or `name.method(...)`, a static method returning `ModuleWithProviders`. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] struct RawRef { file: String, namespace: Option, name: String, method: Option, + /// The `ngModule` of a `{ ngModule: X }` literal. + is_mwp: bool, + /// Written in a `forwardRef`. + forward: bool, +} + +/// A class a list names, after `const` lists are flattened: the reference +/// that names it, where it was found. +struct Flat { + raw: RawRef, + class: ClassRef, + declared: Rc, + /// Reached through a `ModuleWithProviders`. + is_mwp: bool, + /// Reached through a `forwardRef`. + forward: bool, } /// A class a reference resolves to, and the non-relative module specifier @@ -69,6 +87,8 @@ enum Declared { Directive { selector: Option, is_component: bool, + /// What a component brings into an injector through its imports. + imports: CmpImports, }, Pipe { name: Option, @@ -77,9 +97,30 @@ enum Declared { declarations: Vec, imports: Vec, exports: Vec, + /// A declaration file's NgModule, or one with a `providers` key (any + /// value), as ngtsc's `mayDeclareProviders`. + may_declare_providers: bool, }, /// A `const` list of references, spread or named in a module's lists. List(Vec), + /// A `declare const X: readonly [typeof A, typeof B]` list, by its type. + /// Only `ɵinj` reads it. + TypeList(Vec), +} + +/// What a component brings into an NgModule's injector through its +/// imports (ngtsc's `mayExportProviders`). +#[derive(Debug)] +enum CmpImports { + /// A standalone source component's imports. + Read(Vec), + /// Taken to bring in providers: a component of a declaration file + /// (ngtsc's `assumedToExportProviders`), or one that is not standalone + /// (an error in `imports`, so it is kept). + Providers, + /// Not known: its `standalone` or `imports` can't be read. Also used + /// for a directive, which never brings in providers. + Unknown, } /// A directive or pipe of a scope, before it's named for the compiled file. @@ -245,7 +286,7 @@ impl CrossFileAnalyzer { file: file.clone(), namespace: namespace.map(Into::into), name: (*name).into(), - method: None, + ..RawRef::default() }; let class = self.inner.resolve_ref(&raw, None, 0)?; let declared = self.inner.declared(&class.decl)?; @@ -286,93 +327,164 @@ impl CrossFileAnalyzer { .collect() } - /// Which top-level `imports` and `exports` entries of the `@NgModule` - /// class `class_name` of `file` its `ɵinj` leaves out (#623), as ngtsc's - /// `NgModuleDecoratorHandler.resolve` does: an entry whose classes - /// (a `const` list flattened) are all directives or pipes, and in - /// `exports` also components. An exported component must be in the - /// module's compilation scope, so it stays reachable through `imports`. - /// An entry that doesn't resolve, or names an NgModule, stays. - pub(crate) fn injector_drops( + /// What the `ɵinj` of the `@NgModule` class `class_name` of `file` does + /// with each top-level `imports` and `exports` entry (#623), as ngtsc's + /// `NgModuleDecoratorHandler.resolve` does (see `injector_imports`). + /// `consts` are those of `source`, the text of `file` the entries are + /// read from. In `imports`, a directive or pipe is left out, and so is a + /// standalone component that brings in no providers. In `exports`, so + /// is a component of the module's compilation scope, where it stays + /// reachable through `imports`. An entry that doesn't resolve stays. + pub(crate) fn injector_plans( &self, file: &Path, source: &str, class_name: &str, + consts: &StringConsts<'_>, imports: &[Option>], exports: &[Option>], - ) -> InjectorDrops { + ) -> InjectorPlans { let file = self.inner.set_compiled_source(file, source); let inner = &self.inner; let module = Decl { file: file.clone(), name: class_name.into() }; let scope = OnceCell::new(); - let drops = |entries: &[Option>], is_exports: bool| -> Vec { - entries + // The classes `entry` names, `kept` telling which stay. + let resolve = |entry: &InjectorEntry<'_>, kept: &dyn Fn(&Flat) -> Option| { + let mut raws = Vec::new(); + collect_refs(entry.expr, consts, &file, &mut raws, 0, false, false)?; + let refs = inner + .flatten(&raws, None, 0, false, true)? .iter() - .map(|entry| { - let Some(entry) = entry else { return false }; - let raw = RawRef { - file: file.clone(), - namespace: entry.namespace.map(Into::into), - name: entry.name.into(), - method: None, - }; - let Some(classes) = inner.resolve_refs(&[raw], None, 0) else { return false }; - !classes.is_empty() - && classes.iter().all(|(class, declared)| match &**declared { - Declared::Directive { is_component: false, .. } - | Declared::Pipe { .. } => true, - Declared::Directive { is_component: true, .. } => { - is_exports - && scope - .get_or_init(|| inner.compilation_scope(&module)) - .as_ref() - .is_some_and(|scope| scope.contains(&class.decl)) - } - Declared::NgModule { .. } | Declared::List(_) => false, - }) + .map(|flat| { + let kept = kept(flat); + ResolvedRef { + kept, + name: kept + .filter(|kept| *kept) + .and_then(|_| inner.injector_name(flat, &file)), + is_mwp: flat.is_mwp, + forward: flat.forward, + } }) - .collect() + .collect::>(); + Some(refs) }; - InjectorDrops { imports: drops(imports, false), exports: drops(exports, true) } + let imports = imports + .iter() + .map(|entry| { + let Some(entry) = entry else { return EntryPlan::Keep }; + let refs = resolve(entry, &|flat| match &*flat.declared { + Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => { + Some(false) + } + Declared::Directive { .. } => inner.may_export_providers(&flat.class), + Declared::NgModule { .. } => Some(true), + Declared::List(_) | Declared::TypeList(_) => None, + }); + imports_plan(refs.as_deref(), entry.is_spread) + }) + .collect(); + let exports = exports + .iter() + .map(|entry| { + let Some(entry) = entry else { return EntryPlan::Keep }; + let refs = resolve(entry, &|flat| match &*flat.declared { + Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => { + Some(false) + } + // Not known without the module's scope. + Declared::Directive { .. } => scope + .get_or_init(|| inner.compilation_scope(&module)) + .as_ref() + .map(|scope| !scope.contains(&flat.class.decl)), + Declared::NgModule { .. } => Some(true), + Declared::List(_) | Declared::TypeList(_) => None, + }); + exports_plan(refs.as_deref()) + }) + .collect(); + InjectorPlans { imports, exports } } } impl CrossFileAnalyzer { - /// For each `ModuleWithProviders` entry of an NgModule's `imports` in - /// `file` (`source` is its text), the NgModule it names, as `file` - /// would import it, like ngtsc's `resolveTypeList`. An entry is - /// `(namespace, name, method)`: `name.method(...)` (or - /// `namespace.name.method(...)`), or with no method a constant `name` - /// holding one. The method or constant may be in another file; the - /// NgModule is named through an import of the file it comes from, as - /// ngtsc names a class it found in another file (`i1.DepModule`). - /// `None` for an entry that can't be read, or that names no NgModule. - pub(crate) fn module_with_providers_imports( + /// What a binding `file` imports gives in an NgModule's lists (`source` + /// is the text of `file`), like ngtsc's `resolveTypeList`: a class, a + /// `const` list (flattened), or a `ModuleWithProviders` (a method call + /// or a constant), whose NgModule is named through an import of the + /// file it comes from, as ngtsc names a class it found in another file + /// (`i1.DepModule`). `None` when it can't be read, or a + /// `ModuleWithProviders` names no NgModule. + pub(crate) fn read_imported_ref( &self, file: &Path, source: &str, - entries: &[(Option<&str>, &str, Option<&str>)], - ) -> Vec> { + question: &Question<'_>, + ) -> Option { let file = self.inner.set_compiled_source(file, source); - entries - .iter() - .map(|(namespace, name, method)| { - let raw = RawRef { - file: file.clone(), - namespace: namespace.map(Into::into), - name: (*name).into(), - method: None, - }; - let holder = self.inner.resolve_ref(&raw, None, 0)?; - let module = match method { - Some(method) => self.inner.mwp_method_module(&holder, method)?, - None => self.inner.mwp_const_module(&holder)?, - }; - let declared = self.inner.declared(&module.decl)?; - let Declared::NgModule { .. } = &*declared else { return None }; - self.inner.imported_name(&module, &file) + let inner = &self.inner; + let raw = RawRef { + file: file.clone(), + namespace: question.namespace.map(Into::into), + name: question.name.into(), + ..RawRef::default() + }; + let holder = inner.resolve_ref(&raw, None, 0)?; + // Whether a class may not be defined yet when this file runs. + let unready = |class: &ClassRef| { + inner + .imported_name(class, &file) + .is_none_or(|name| inner.loads_back(&file, &name, &class.decl)) + }; + let named = |class: &ClassRef| { + Some(match inner.output_name(class, &file)? { + ImportedName::Local(name) => NamedClass::Local(name), + ImportedName::Module { specifier, name } => NamedClass::Module { specifier, name }, }) - .collect() + }; + // A `ModuleWithProviders` names an NgModule. + let ng_module = |module: ClassRef| { + let Declared::NgModule { .. } = &*inner.declared(&module.decl)? else { return None }; + let unready = unready(&module); + Some(Answer::Classes { classes: vec![named(&module)?], unready, forward_ref: false }) + }; + if let Some(method) = question.method { + return ng_module(inner.mwp_method_module(&holder, method)?); + } + match inner.declared(&holder.decl).as_deref() { + // A spread must be of an array (`read_declared` also gives a + // list for `const X = A` or `const X = A.forRoot()`). + Some(Declared::List(items)) if inner.is_const_array(&holder) => { + let mut classes = Vec::new(); + let mut forward_ref = false; + inner.flatten_list(&holder, items, &mut classes, &mut forward_ref, 0)?; + // Full compilation lists the binding as written, so the file + // it is imported from must not load this one back either. + let unready = classes.iter().any(unready) + || inner.binding_loads_back(&file, question.namespace.unwrap_or(question.name)); + let classes = classes.iter().map(named).collect::>()?; + Some(Answer::Classes { classes, unready, forward_ref }) + } + _ if question.spread => None, + // A constant holding a `ModuleWithProviders`. + Some(Declared::List(_)) | None => ng_module(inner.mwp_const_module(&holder)?), + // A class. + Some(_) => { + let unready = unready(&holder); + Some(match question.namespace { + // Read through the binding, so the file it is imported + // from (a barrel, say) must not load this one back. + None => Answer::AsWritten { + unready: unready || inner.binding_loads_back(&file, question.name), + }, + Some(_) => Answer::Classes { + classes: vec![named(&holder)?], + unready, + forward_ref: false, + }, + }) + } + } } } @@ -385,11 +497,11 @@ fn dedupe(items: Vec) -> Vec { /// The scope kind of a directive or pipe. fn scope_kind(declared: &Declared) -> Option { match declared { - Declared::Directive { selector, is_component } => { + Declared::Directive { selector, is_component, .. } => { Some(ScopeKind::Directive { selector: selector.clone(), is_component: *is_component }) } Declared::Pipe { name } => Some(ScopeKind::Pipe { name: name.clone() }), - Declared::NgModule { .. } | Declared::List(_) => None, + Declared::NgModule { .. } | Declared::List(_) | Declared::TypeList(_) => None, } } @@ -410,7 +522,7 @@ fn real_path(file: &Path) -> String { } /// Whether `file` is in a package rather than the workspace. -fn in_node_modules(file: &str) -> bool { +pub(super) fn in_node_modules(file: &str) -> bool { Path::new(file).components().any(|part| part.as_os_str() == "node_modules") } @@ -439,7 +551,7 @@ impl Inner { /// Resolve `spec` the way TypeScript does from `from_file`: `.d.ts` /// files for packages, `.ts` for `./x.js`. - fn resolve_types(&self, from_file: &str, spec: &str) -> Option { + pub(super) fn resolve_types(&self, from_file: &str, spec: &str) -> Option { let resolver = self.scope.resolver.get_or_init(|| { // A package's `types` export, as `tsc` reads it. let options = oxc_resolver::ResolveOptions { @@ -470,6 +582,15 @@ impl Inner { }) } + /// Whether the file `file` imports `binding` from loads `file` back. + fn binding_loads_back(&self, file: &str, binding: &str) -> bool { + let Some(exports) = self.file_exports(file) else { return true }; + let Some(Local::Import { spec, .. }) = exports.locals.get(binding) else { return false }; + self.resolve_types(file, spec).is_none_or(|from| { + from != file && real_path(Path::new(&from)) != file && self.imports_file(&from, file) + }) + } + /// Whether loading the workspace file `from` loads `target`: whether it /// imports or re-exports it, directly or through other workspace files. fn imports_file(&self, from: &str, target: &str) -> bool { @@ -602,7 +723,7 @@ impl Inner { file: file.clone(), namespace: namespace.map(Into::into), name: class.into(), - method: None, + ..RawRef::default() }, method: method.into(), fallback: match fallback { @@ -655,7 +776,7 @@ impl Inner { file: file.clone(), namespace: namespace.map(Into::into), name: class.into(), - method: None, + ..RawRef::default() }, Some(method.to_string()), )), @@ -669,6 +790,59 @@ impl Inner { } } + /// Whether `decl` is a `const` whose value is an array literal. + fn is_const_array(&self, decl: &ClassRef) -> bool { + self.parsed_file(&decl.decl.file).is_some_and(|parsed| { + parsed.with(|_, program, _| { + top_level_const_init(program, &decl.decl.name) + .is_some_and(|init| matches!(evaluated(init), Expression::ArrayExpression(_))) + }) + }) + } + + /// The classes of the `const` list `list` (its `items`), flattened, each + /// `ModuleWithProviders` as its NgModule. `forward_ref` is set when a + /// list writes a `forwardRef`. + fn flatten_list( + &self, + list: &ClassRef, + items: &[RawRef], + out: &mut Vec, + forward_ref: &mut bool, + depth: u16, + ) -> Option<()> { + if depth > MAX_EXPORT_CHAIN || !self.scope.flattening.borrow_mut().insert(list.decl.clone()) + { + return None; + } + *forward_ref |= self.parsed_file(&list.decl.file).is_some_and(|parsed| { + parsed.with(|_, program, _| { + top_level_const_init(program, &list.decl.name).is_some_and(writes_forward_ref) + }) + }); + let read = items.iter().try_for_each(|raw| { + let class = self.resolve_ref(raw, list.owner.as_deref(), 0)?; + match &*self.declared(&class.decl)? { + Declared::List(items) if raw.method.is_none() => { + self.flatten_list(&class, items, out, forward_ref, depth + 1) + } + Declared::NgModule { .. } => { + out.push(class); + Some(()) + } + // A declaration file's list type is not read here. + Declared::TypeList(_) => None, + _ if raw.method.is_some() => None, + _ => { + out.push(class); + Some(()) + } + } + }); + self.scope.flattening.borrow_mut().remove(&list.decl); + read + } + /// What the declaration `decl` is to a module scope. fn declared(&self, decl: &Decl) -> Option> { if let Some(declared) = self.scope.declared.borrow().get(decl) { @@ -688,6 +862,21 @@ impl Inner { owner: Option<&str>, depth: u16, ) -> Option)>> { + let flat = self.flatten(refs, owner, depth, false, false)?; + Some(flat.into_iter().map(|flat| (flat.class, flat.declared)).collect()) + } + + /// The classes `refs` name, with `const` lists flattened, each with the + /// reference that names it. `forward`: `refs` are read through a + /// `forwardRef`. `types`: read [`Declared::TypeList`]s too. + fn flatten( + &self, + refs: &[RawRef], + owner: Option<&str>, + depth: u16, + forward: bool, + types: bool, + ) -> Option> { if depth > MAX_EXPORT_CHAIN { return None; } @@ -695,20 +884,102 @@ impl Inner { for raw in refs { let class = self.resolve_ref(raw, owner, 0)?; let declared = self.declared(&class.decl)?; - if let Declared::List(items) = &*declared { + let forward = forward || raw.forward; + let items = match &*declared { + Declared::List(items) => Some(items), + Declared::TypeList(items) if types => Some(items), + Declared::TypeList(_) => return None, + _ => None, + }; + if let Some(items) = items { if !self.scope.flattening.borrow_mut().insert(class.decl.clone()) { return None; } - let items = self.resolve_refs(items, class.owner.as_deref(), depth + 1); + let items = self.flatten(items, class.owner.as_deref(), depth + 1, forward, types); self.scope.flattening.borrow_mut().remove(&class.decl); out.extend(items?); } else { - out.push((class, declared)); + let is_mwp = raw.is_mwp || raw.method.is_some(); + out.push(Flat { raw: raw.clone(), class, declared, is_mwp, forward }); } } Some(out) } + /// Whether the class may bring providers into an NgModule's injector, + /// like ngtsc's `mayExportProviders`: whether it reaches an NgModule + /// with providers through the `imports` of standalone components and + /// NgModules. A component's own providers don't count, and the + /// providers of a `ModuleWithProviders` an NgModule imports aren't read + /// either: its NgModule is. `None` when a class it reaches can't be + /// read and none is known to bring in providers. + fn may_export_providers(&self, class: &ClassRef) -> Option { + // ngtsc stops at a class it is already looking at, so from the top + // this is whether such a class can be reached. + let mut seen = FxHashSet::default(); + let mut pending = vec![class.clone()]; + let mut unknown = false; + while let Some(class) = pending.pop() { + if !seen.insert(class.decl.clone()) { + continue; + } + let Some(declared) = self.declared(&class.decl) else { + unknown = true; + continue; + }; + let imports = match &*declared { + Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => continue, + Declared::Directive { imports: CmpImports::Read(imports), .. } + | Declared::NgModule { may_declare_providers: false, imports, .. } => imports, + Declared::Directive { imports: CmpImports::Providers, .. } + | Declared::NgModule { .. } => return Some(true), + Declared::Directive { imports: CmpImports::Unknown, .. } + | Declared::List(_) + | Declared::TypeList(_) => { + unknown = true; + continue; + } + }; + let Some(flat) = self.flatten(imports, class.owner.as_deref(), 0, false, true) else { + unknown = true; + continue; + }; + pending.extend(flat.into_iter().map(|flat| flat.class)); + } + (!unknown).then_some(false) + } + + /// How the compiled `file` writes the class `flat` names, by itself, + /// like ngtsc's `ReferenceEmitter`: the identifier `file` wrote for it, + /// else its name when `file` declares it, else an import of it. + fn injector_name(&self, flat: &Flat, file: &str) -> Option { + let raw = &flat.raw; + if raw.file == file && raw.namespace.is_none() && raw.method.is_none() && !raw.is_mwp { + return Some(ImportedName::Local(raw.name.clone())); + } + let class = &flat.class; + // A class reached through a package is imported from it, or not at + // all (ngtsc: "Unable to import class"). + if let Some(specifier) = class.owner.as_ref().filter(|_| class.decl.file != file) { + let entry = self.resolve_types(file, specifier)?; + let name = self.export_name(&entry, &class.decl)?; + return Some(ImportedName::Module { specifier: specifier.clone(), name }); + } + self.output_name(class, file) + } + + /// [`Self::imported_name`] for a reference this file's output adds: + /// `None` for a relative path into `node_modules`. ngtsc writes one for a + /// package class reached through a relative `export *`, but it may not + /// resolve (a package's typings and code sit in different files) and, + /// under a dev server, loads a second copy of the package. + fn output_name(&self, class: &ClassRef, file: &str) -> Option { + let name = self.imported_name(class, file)?; + let into_package = matches!(&name, ImportedName::Module { specifier, .. } + if is_relative(specifier) && in_node_modules(&class.decl.file)); + (!into_package).then_some(name) + } + /// The classes of the compilation scope of the NgModule `module`: its /// declarations, the standalone classes it imports, and what the /// NgModules it imports export (ngtsc's `LocalModuleScopeRegistry`). @@ -1055,8 +1326,23 @@ fn read_declared<'a>( if !vars.kind.is_const() { return None; } + let Some(init) = &var.init else { + // `declare const X: readonly [typeof A, typeof B]`, as a + // declaration file writes a list. + if !vars.declare { + return None; + } + let mut ty = &var.type_annotation.as_ref()?.type_annotation; + if let TSType::TSTypeOperatorType(operator) = ty + && operator.operator == TSTypeOperatorOperator::Readonly + { + ty = &operator.type_annotation; + } + let TSType::TSTupleType(_) = ty else { return None }; + return Some(Declared::TypeList(type_list(ty, &decl.file)?)); + }; let mut refs = Vec::new(); - collect_refs(var.init.as_ref()?, consts, &decl.file, &mut refs, 0)?; + collect_refs(init, consts, &decl.file, &mut refs, 0, false, false)?; Some(Declared::List(refs)) }) } @@ -1084,8 +1370,18 @@ fn read_declaration_type(class: &Class<'_>, file: &str) -> Option { _ => None, }; match key.name.as_str() { - "ɵcmp" => Some(Declared::Directive { selector: string_at(1), is_component: true }), - "ɵdir" => Some(Declared::Directive { selector: string_at(1), is_component: false }), + // ngtsc takes a component of a declaration file to bring in + // providers. + "ɵcmp" => Some(Declared::Directive { + selector: string_at(1), + is_component: true, + imports: CmpImports::Providers, + }), + "ɵdir" => Some(Declared::Directive { + selector: string_at(1), + is_component: false, + imports: CmpImports::Unknown, + }), "ɵpipe" => Some(Declared::Pipe { name: string_at(1) }), "ɵmod" => { let list = |index: usize| type_list(arguments.get(index)?, file); @@ -1093,6 +1389,7 @@ fn read_declaration_type(class: &Class<'_>, file: &str) -> Option { declarations: list(1)?, imports: list(2)?, exports: list(3)?, + may_declare_providers: true, }) } _ => None, @@ -1129,7 +1426,7 @@ fn type_name_ref(file: &str, name: &TSTypeName<'_>) -> Option { }, TSTypeName::ThisExpression(_) => return None, }; - Some(RawRef { file: file.into(), namespace, name, method: None }) + Some(RawRef { file: file.into(), namespace, name, ..RawRef::default() }) } /// The class a value names: `A` or `ns.A`. @@ -1144,7 +1441,7 @@ fn value_ref(value: &Expression<'_>, file: &str) -> Option { } _ => return None, }; - Some(RawRef { file: file.into(), namespace, name, method: None }) + Some(RawRef { file: file.into(), namespace, name, ..RawRef::default() }) } /// The scope the Angular decorator of a source `class` gives it. @@ -1177,17 +1474,40 @@ fn read_decorator<'a>( }; match kind { "Component" => { - Some(Declared::Directive { selector: string("selector"), is_component: true }) - } - "Directive" => { - Some(Declared::Directive { selector: string("selector"), is_component: false }) + // A component that is not standalone is an error in `imports`: + // it is kept. + let standalone = match field("standalone").map(Expression::get_inner_expression) { + None => Some(true), + Some(Expression::BooleanLiteral(b)) => Some(b.value), + Some(_) => None, + }; + let imports = match standalone { + Some(true) => { + let mut refs = Vec::new(); + match field("imports") { + Some(value) => { + collect_refs(value, consts, file, &mut refs, 0, false, false) + .map_or(CmpImports::Unknown, |()| CmpImports::Read(refs)) + } + None => CmpImports::Read(refs), + } + } + Some(false) => CmpImports::Providers, + None => CmpImports::Unknown, + }; + Some(Declared::Directive { selector: string("selector"), is_component: true, imports }) } + "Directive" => Some(Declared::Directive { + selector: string("selector"), + is_component: false, + imports: CmpImports::Unknown, + }), "Pipe" => Some(Declared::Pipe { name: string("name") }), _ => { let list = |key: &str| -> Option> { let mut refs = Vec::new(); if let Some(value) = field(key) { - collect_refs(value, consts, file, &mut refs, 0)?; + collect_refs(value, consts, file, &mut refs, 0, false, false)?; } Some(refs) }; @@ -1195,21 +1515,38 @@ fn read_decorator<'a>( declarations: list("declarations")?, imports: list("imports")?, exports: list("exports")?, + may_declare_providers: field("providers").is_some(), }) } } } +/// Whether a list writes `forwardRef(...)`, in it or in an array it holds. +fn writes_forward_ref(expr: &Expression<'_>) -> bool { + match expr.get_inner_expression() { + Expression::ArrayExpression(array) => array.elements.iter().any(|element| match element { + ArrayExpressionElement::SpreadElement(spread) => writes_forward_ref(&spread.argument), + _ => element.as_expression().is_some_and(writes_forward_ref), + }), + Expression::CallExpression(call) => { + matches!(call.callee.get_inner_expression(), Expression::Identifier(id) if id.name == "forwardRef") + } + _ => false, + } +} + /// Collect the references `expr` lists into `out`: a class (`A`, `ns.A`, /// `forwardRef(() => A)`), a `ModuleWithProviders` (`A.forRoot()`, /// `{ ngModule: A }`), or an array of those, spread or nested. `None` for -/// anything else. +/// anything else. `is_mwp` and `forward` mark what `expr` is inside of. fn collect_refs( expr: &Expression<'_>, consts: &StringConsts<'_>, file: &str, out: &mut Vec, depth: u16, + is_mwp: bool, + forward: bool, ) -> Option<()> { if depth > MAX_EXPORT_CHAIN { return None; @@ -1219,16 +1556,34 @@ fn collect_refs( namespace: namespace.map(Into::into), name: name.into(), method: method.map(Into::into), + is_mwp, + forward, }; match expr.get_inner_expression() { Expression::ArrayExpression(array) => { for element in &array.elements { match element { ArrayExpressionElement::SpreadElement(spread) => { - collect_refs(&spread.argument, consts, file, out, depth + 1)?; + collect_refs( + &spread.argument, + consts, + file, + out, + depth + 1, + is_mwp, + forward, + )?; } ArrayExpressionElement::Elision(_) => return None, - _ => collect_refs(element.as_expression()?, consts, file, out, depth + 1)?, + _ => collect_refs( + element.as_expression()?, + consts, + file, + out, + depth + 1, + is_mwp, + forward, + )?, } } } @@ -1261,7 +1616,7 @@ fn collect_refs( } _ => return None, }; - collect_refs(returned, consts, file, out, depth + 1)?; + collect_refs(returned, consts, file, out, depth + 1, is_mwp, true)?; } // `A.forRoot()`, `ns.A.forRoot()` Expression::StaticMemberExpression(member) => { @@ -1288,7 +1643,7 @@ fn collect_refs( } _ => None, })?; - collect_refs(module, consts, file, out, depth + 1)?; + collect_refs(module, consts, file, out, depth + 1, true, forward)?; } _ => return None, } diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index 761cc40a7..1e2116528 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -64,6 +64,10 @@ pub struct ImportElisionAnalyzer<'a> { /// doesn't (see [`Self::preserve`] and the post-passes in /// [`Self::analyze`]), so `@defer` can't remove them. kept_by_output: FxHashSet>, + /// Default imports the source only names in types, kept because a ctor + /// param `type:` names them (see [`Self::release_const_enum_ctor_types`]). + #[cfg(feature = "cross_file_elision")] + kept_for_ctor_types: FxHashSet>, /// The non-`type` specifiers of each import declaration, by local name. declaration_specifiers: Vec>>, /// Index into `declaration_specifiers` of each non-`type` specifier. @@ -206,8 +210,13 @@ impl<'a> ImportElisionAnalyzer<'a> { // `Foo` would dangle. let ctor_type_names = Self::collect_ctor_param_type_leftmost_names(program, &consts, emit_metadata); + #[cfg(feature = "cross_file_elision")] + let mut kept_for_ctor_types = FxHashSet::default(); for name in &ctor_type_names { - type_only_specifiers.remove(name); + if type_only_specifiers.remove(name) { + #[cfg(feature = "cross_file_elision")] + kept_for_ctor_types.insert(*name); + } } kept_by_output.extend(ctor_type_names); @@ -215,11 +224,41 @@ impl<'a> ImportElisionAnalyzer<'a> { type_only_specifiers, value_reference_counts, kept_by_output, + #[cfg(feature = "cross_file_elision")] + kept_for_ctor_types, declaration_specifiers, declaration_of, } } + /// Remove again the default imports kept only for a ctor param type + /// that is an imported `const enum`: the metadata writes `type: undefined` + /// for it (see [`crate::directive::StringConsts::is_imported_const_enum`]), + /// so nothing reads the import any more. ngtsc drops it too. + /// + /// `consts` must hold the imported const enums. + #[cfg(feature = "cross_file_elision")] + pub(crate) fn release_const_enum_ctor_types( + &mut self, + program: &'a Program<'a>, + consts: &crate::directive::StringConsts<'a>, + emit_metadata: bool, + ) { + if self.kept_for_ctor_types.is_empty() { + return; + } + let still_named = + Self::collect_ctor_param_type_leftmost_names(program, consts, emit_metadata); + self.kept_for_ctor_types.retain(|name| { + if still_named.contains(name) { + return true; + } + self.type_only_specifiers.insert(*name); + self.kept_by_output.remove(name); + false + }); + } + /// Local names of **default imports** that a decorated class's constructor /// parameter annotation references bare (`x: Foo` after `T | null` /// narrowing, not shadowed by a class type parameter) — the names @@ -262,6 +301,7 @@ impl<'a> ImportElisionAnalyzer<'a> { for declared in crate::util::declared_classes(program) { Self::collect_ctor_type_uses_from_class( declared.class, + !declared.nested, &default_imports, consts, emit_metadata, @@ -277,6 +317,7 @@ impl<'a> ImportElisionAnalyzer<'a> { /// emit the local name. fn collect_ctor_type_uses_from_class( class: &'a oxc_ast::ast::Class<'a>, + top_level: bool, default_imports: &FxHashSet<&'a str>, consts: &crate::directive::StringConsts<'a>, emit_metadata: bool, @@ -296,14 +337,13 @@ impl<'a> ImportElisionAnalyzer<'a> { // factory's token (`getConstructorDependencies` in // `annotations/common/src/di.ts`), so only the metadata's // `type:` still names it. - if !emit_metadata - && param.decorators.iter().any(|d| { - matches!( - crate::directive::angular_param_decorator(d, Some(consts)), - Some("Inject" | "Attribute") - ) - }) - { + let token_replaced = param.decorators.iter().any(|d| { + matches!( + crate::directive::angular_param_decorator(d, Some(consts)), + Some("Inject" | "Attribute") + ) + }); + if !emit_metadata && token_replaced { continue; } let Some(annotation) = ¶m.type_annotation else { @@ -320,6 +360,12 @@ impl<'a> ImportElisionAnalyzer<'a> { let Some(leftmost) = path.first() else { continue; }; + // Here only the metadata names the type, and it writes + // `type: undefined` for an imported `const enum` in a + // top-level class (see `build_param_type_expression`). + if token_replaced && top_level && consts.is_imported_const_enum(&path) { + continue; + } if !type_params.contains(leftmost) && default_imports.contains(leftmost.as_str()) { names.insert(*leftmost); } diff --git a/crates/oxc_angular_compiler/src/component/mod.rs b/crates/oxc_angular_compiler/src/component/mod.rs index ba0433b42..658042b1f 100644 --- a/crates/oxc_angular_compiler/src/component/mod.rs +++ b/crates/oxc_angular_compiler/src/component/mod.rs @@ -35,6 +35,7 @@ pub use dependency::{ }; pub use hmr_fingerprint::{ComponentHmrFingerprint, component_hmr_fingerprint}; pub use import_elision::ImportElisionAnalyzer; +pub(crate) use metadata::ImportedName; pub use metadata::{ AngularVersion, ChangeDetectionStrategy, ComponentMetadata, DeclarationListEmitMode, HostDirectiveMetadata, HostMetadata, ImportRef, LifecycleMetadata, Prerelease, diff --git a/crates/oxc_angular_compiler/src/component/source_locations.rs b/crates/oxc_angular_compiler/src/component/source_locations.rs index f064ded5f..ce2b196d7 100644 --- a/crates/oxc_angular_compiler/src/component/source_locations.rs +++ b/crates/oxc_angular_compiler/src/component/source_locations.rs @@ -23,20 +23,68 @@ pub(super) fn supports_attach_source_locations(version: Option) /// /// Mirrors ngtsc's `getProjectRelativePath` (compiler-cli `util/src/path.ts`), /// with root dirs resolved against the working directory like `getRootDirs`. -/// Unlike ngtsc, the path is never lowercased: ngtsc's `getCanonicalFileName` -/// lowercases it on a case-insensitive file system (macOS, Windows), so the -/// two only match there for lowercase paths. -pub(super) fn project_relative_path(file: &str, root_dirs: &[String]) -> Option { - let file = resolve(file); +/// When `case_sensitive` is `false`, both paths are compared and given in +/// lowercase, like ngtsc's `getCanonicalFileName` on a case-insensitive file +/// system (macOS, Windows). +pub(super) fn project_relative_path( + file: &str, + root_dirs: &[String], + case_sensitive: bool, +) -> Option { + let cwd = || std::env::current_dir().ok().map(|cwd| cwd.to_string_lossy().into_owned()); + project_relative_path_in(file, root_dirs, case_sensitive, &cwd) +} + +/// [`project_relative_path`] with the working directory from `cwd`. +fn project_relative_path_in( + file: &str, + root_dirs: &[String], + case_sensitive: bool, + cwd: &dyn Fn() -> Option, +) -> Option { + // ngtsc lowercases after the path is made absolute, so the working + // directory part is lowercased too. + let canonical = |path: &str| { + let segments = resolve(path, cwd); + if case_sensitive { + segments + } else { + segments.iter().map(|segment| to_file_name_lower_case(segment)).collect() + } + }; + let file = canonical(file); root_dirs.iter().find_map(|root| { - let rel = relative(&resolve(root), &file); + let rel = relative(&canonical(root), &file); (!rel.starts_with("..")).then_some(rel) }) } -/// The path segments of `path` made absolute against the working directory, -/// with `.` and `..` resolved and `\` read as `/`. -fn resolve(path: &str) -> Vec { +/// TypeScript's `toFileNameLowerCase`, the `getCanonicalFileName` of its +/// compiler host on a case-insensitive file system. Only runs of characters +/// other than `İ`, `ı`, `ß`, `a-z`, `0-9`, `\`, `/`, `:`, `-`, `_`, `.` and +/// space are lowercased, each on its own: `İ` stays, and `ΑΣa` gives `αςa` +/// (a final sigma), not `ασa`. +fn to_file_name_lower_case(path: &str) -> String { + let kept = |c: char| { + matches!(c, '\u{130}' | '\u{131}' | 'ß' | 'a'..='z' | '0'..='9') + || matches!(c, '\\' | '/' | ':' | '-' | '_' | '.' | ' ') + }; + let mut out = String::with_capacity(path.len()); + let mut rest = path; + while let Some(start) = rest.find(|c| !kept(c)) { + out.push_str(&rest[..start]); + let run = &rest[start..]; + let end = run.find(kept).unwrap_or(run.len()); + out.push_str(&run[..end].to_lowercase()); + rest = &run[end..]; + } + out.push_str(rest); + out +} + +/// The path segments of `path` made absolute against the working directory +/// from `cwd`, with `.` and `..` resolved and `\` read as `/`. +fn resolve(path: &str, cwd: &dyn Fn() -> Option) -> Vec { let path = path.replace('\\', "/"); let bytes = path.as_bytes(); let is_absolute = path.starts_with('/') @@ -46,9 +94,9 @@ fn resolve(path: &str) -> Vec { } else { // Without a working directory (wasm), a relative path stays relative: // two such paths still compare against the same unknown base. - match std::env::current_dir() { - Ok(cwd) => format!("{}/{path}", cwd.to_string_lossy().replace('\\', "/")), - Err(_) => path, + match cwd() { + Some(cwd) => format!("{}/{path}", cwd.replace('\\', "/")), + None => path, } }; let mut segments: Vec = Vec::new(); @@ -93,6 +141,7 @@ pub(super) fn template_source<'a>( file_path: &str, source: &str, root_dirs: Option<&[String]>, + case_sensitive: bool, resolved_resources: Option<&ResolvedResources>, ) -> Option<(Ident<'a>, TemplateSource<'a>)> { let root_dirs = root_dirs?; @@ -116,14 +165,18 @@ pub(super) fn template_source<'a>( Some(i) => format!("{}/{url}", &file[..i]), None => url, }; - let path = project_relative_path(&template_file, root_dirs)?; + let path = project_relative_path(&template_file, root_dirs, case_sensitive)?; return Some((Ident::from(allocator.alloc_str(&path)), whole(template))); } if metadata.template.as_ref().is_none_or(|inline| inline.as_str() != template) { return None; } - let path = Ident::from(allocator.alloc_str(&project_relative_path(file_path, root_dirs)?)); + let path = Ident::from(allocator.alloc_str(&project_relative_path( + file_path, + root_dirs, + case_sensitive, + )?)); let Some(span) = metadata.template_span else { return Some((path, whole(template))); }; @@ -244,16 +297,66 @@ mod tests { #[test] fn relative_to_the_first_root_that_contains_the_file() { let roots = ["/w/lib".to_string(), "/w".to_string()]; - assert_eq!(project_relative_path("/w/src/a.ts", &roots).as_deref(), Some("src/a.ts")); - assert_eq!(project_relative_path("/w/lib/b.ts", &roots).as_deref(), Some("b.ts")); - assert_eq!(project_relative_path("/x/a.ts", &roots), None); - assert_eq!(project_relative_path("/w/./src/../a.ts", &roots).as_deref(), Some("a.ts")); + let rel = |file| project_relative_path(file, &roots, true); + assert_eq!(rel("/w/src/a.ts").as_deref(), Some("src/a.ts")); + assert_eq!(rel("/w/lib/b.ts").as_deref(), Some("b.ts")); + assert_eq!(rel("/x/a.ts"), None); + assert_eq!(rel("/w/./src/../a.ts").as_deref(), Some("a.ts")); assert_eq!( - project_relative_path("C:\\w\\src\\a.ts", &["C:/w".to_string()]).as_deref(), + project_relative_path("C:\\w\\src\\a.ts", &["C:/w".to_string()], true).as_deref(), Some("src/a.ts") ); } + /// `project_relative_path` on a case-insensitive file system, with the + /// working directory `/W/Proj`. + fn insensitive(file: &str, roots: &[&str]) -> Option { + let roots: Vec = roots.iter().map(ToString::to_string).collect(); + project_relative_path_in(file, &roots, false, &|| Some("/W/Proj".to_string())) + } + + #[test] + fn lowercased_on_a_case_insensitive_file_system() { + // As ngtsc 22.2.3 gives them on macOS. + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["/W/Proj"]).as_deref(), Some("src/a.ts")); + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["/w/proj"]).as_deref(), Some("src/a.ts")); + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["/W/Proj/"]).as_deref(), Some("src/a.ts")); + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["/W/Other"]), None); + // A relative root resolves against the working directory, and the + // whole path is lowercased after that. + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["Src"]).as_deref(), Some("a.ts")); + assert_eq!(insensitive("/W/Proj/Src/A.ts", &["src"]).as_deref(), Some("a.ts")); + assert_eq!(insensitive("/w/proj/src/A.ts", &["src"]).as_deref(), Some("a.ts")); + // With the case kept, a root in another case holds nothing. + let cwd = || Some("/W/Proj".to_string()); + assert_eq!( + project_relative_path_in("/W/Proj/Src/A.ts", &["src".to_string()], true, &cwd), + None + ); + } + + #[test] + fn lowercased_windows_paths() { + // The drive letter is compared without case too. + assert_eq!(insensitive("C:\\W\\Src\\A.ts", &["C:/W"]).as_deref(), Some("src/a.ts")); + assert_eq!(insensitive("c:/w/src/a.ts", &["C:/W"]).as_deref(), Some("src/a.ts")); + assert_eq!(insensitive("C:\\W\\Src\\A.ts", &["c:\\w"]).as_deref(), Some("src/a.ts")); + // With the case kept, as before. + assert_eq!(project_relative_path("C:\\W\\Src\\A.ts", &["c:/W".to_string()], true), None); + } + + #[test] + fn lowercased_like_typescript() { + // ngtsc 22.2.3 output on macOS. TypeScript keeps `İ` and lowercases + // each run between `a-z`, `0-9`, `.`, `/` and the like on its own. + let rel = |file| insensitive(file, &["/w"]); + assert_eq!(rel("/w/Ä/App.ts").as_deref(), Some("ä/app.ts")); + assert_eq!(rel("/w/ΑΣ/X.ts").as_deref(), Some("ας/x.ts")); + assert_eq!(rel("/w/ΑΣ.ts").as_deref(), Some("ας.ts")); + assert_eq!(rel("/w/ΑΣa/Y.ts").as_deref(), Some("αςa/y.ts")); + assert_eq!(rel("/w/İx/D.ts").as_deref(), Some("İx/d.ts")); + } + #[test] fn cooks_escapes_to_their_backslash() { let (cooked, offsets) = cook_literal( diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index f905ce938..9f843b747 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -277,9 +277,19 @@ pub struct TransformOptions { /// /// Default: `None`, which keeps `filePath` as the path given to the /// transform. ngtsc instead falls back to `rootDir`, then the working - /// directory, and lowercases paths on a case-insensitive file system; - /// oxc never lowercases. + /// directory. pub root_dirs: Option>, + + /// TypeScript's `useCaseSensitiveFileNames` (`ts.sys`): whether the file + /// system tells `App.ts` from `app.ts`. When `false` (macOS and Windows + /// by default), the paths made relative to `root_dirs` are compared and + /// written in lowercase, as ngtsc's `getCanonicalFileName` does: + /// `ɵsetClassDebugInfo`'s `filePath` and the `ɵɵattachSourceLocations` + /// path. The HMR id and import paths keep their case, as in ngtsc. + /// + /// Default: `true`, which keeps the case (and the only safe value + /// without a file system to check, as in wasm). + pub use_case_sensitive_file_names: bool, } /// Input for host metadata when passed via TransformOptions. @@ -339,6 +349,7 @@ impl Default for TransformOptions { forbid_orphan_components: false, enable_template_source_locations: false, root_dirs: None, + use_case_sensitive_file_names: true, } } } @@ -582,8 +593,8 @@ pub struct ImportInfo<'a> { /// declaration) while `{ default as Foo }` resolves IMPORTED /// (`i1.default`). pub is_default_import: bool, - /// Whether this named import is a `const enum` in the file it comes from, - /// as far as the cross-file analysis can tell (only with + /// Whether this named or default import is a `const enum` where it is + /// declared, as far as the cross-file analysis can tell (only with /// `resolve_imported_values`). Read only by the class metadata `type:` of /// a constructor parameter, which ngtsc writes as `undefined` for one. /// DI tokens, `imports:` and `@defer` don't read it. @@ -3557,17 +3568,26 @@ pub fn transform_angular_file( } } - // A named import used as a constructor parameter type that the other - // file declares as a `const enum`: class metadata writes `type: undefined` - // for it, like ngtsc's full and partial `typeToValue`. Gated like the - // other cross-file reads of `resolve_imported_values`, and only the - // names a constructor parameter type uses are looked up. + // A constructor parameter type that starts at an import and ends at a + // `const enum` where it is declared: class metadata writes + // `type: undefined` for it, like ngtsc's full and partial `typeToValue`. + // Gated like the other cross-file reads of `resolve_imported_values`, + // and only the types a constructor parameter uses are looked up. #[cfg(feature = "cross_file_elision")] - if options.resolve_imported_values - && let Some(analyzer) = &cross_file_analyzer - { - mark_const_enum_imports(&parser_ret.program, &mut import_map, analyzer, path); - } + let string_consts = match &cross_file_analyzer { + Some(analyzer) if options.resolve_imported_values => { + let found = imported_const_enums(&parser_ret.program, &mut import_map, analyzer, path); + string_consts.with_imported_const_enums(found) + } + _ => string_consts, + }; + // A default import kept only for such a `type:` is not needed now. + #[cfg(feature = "cross_file_elision")] + import_elision_mut.release_const_enum_ctor_types( + &parser_ret.program, + &string_consts, + options.emit_class_metadata && !options.advanced_optimizations, + ); // ngtsc's implicit signal `debugName` transform: `signal(0)` and the // other signal APIs get an ngDevMode `debugName` argument named after the @@ -4760,17 +4780,17 @@ pub fn transform_angular_file( ); } - // A partial declaration lists the NgModule of each - // `ModuleWithProviders` import, like ngtsc. A class in a - // function may see local bindings: it stays as before. - if matches!(options.compilation_mode, crate::CompilationMode::Partial) - && !site.nested - { - ng_module_metadata.partial_imports = partial_module_with_providers_imports( + // Read the lists like ngtsc: in partial mode the + // declaration lists what they give, and in both modes + // they are wrapped when one names a class declared + // later. A class in a function may see local bindings: + // it stays as before. + if !site.nested { + resolve_ng_module_lists( allocator, &parser_ret.program, class, - &ng_module_metadata, + &mut ng_module_metadata, &string_consts, &import_map, &import_elision_mut, @@ -4779,33 +4799,45 @@ pub fn transform_angular_file( .as_ref() .filter(|_| options.resolve_imported_values) .map(|analyzer| (analyzer, path, source)), - &mut file_namespace_registry, + matches!(options.compilation_mode, crate::CompilationMode::Partial) + .then_some(&mut file_namespace_registry), ); } // Like ngtsc in full and partial compilation, ɵinj leaves - // out the directives and pipes of `imports` and `exports` - // (#623). A nested class may name a binding of its - // function, so it keeps the lists as written. + // out the directives, pipes and provider-less standalone + // components of `imports`, and lists what it keeps of a + // partly left out entry one by one (#623). A nested class + // may name a binding of its function, so it keeps the + // lists as written. if !site.nested && let Some(entries) = crate::ng_module::injector_entries(class, &string_consts) { #[cfg_attr(not(feature = "cross_file_elision"), allow(unused_mut))] - let mut drops = entries.local_drops(&parser_ret.program, &string_consts); + let mut plans = entries.local_plans(&parser_ret.program, &string_consts); #[cfg(feature = "cross_file_elision")] if options.resolve_imported_values && let Some(analyzer) = &cross_file_analyzer { - drops.merge(&analyzer.injector_drops( + plans.merge(&analyzer.injector_plans( std::path::Path::new(path), source, ng_module_metadata.class_name.as_str(), + &string_consts, entries.imports(), entries.exports(), )); } - entries.apply(allocator, &mut ng_module_metadata, &drops); + entries.apply( + allocator, + &mut ng_module_metadata, + &plans, + &mut |specifier| { + file_namespace_registry + .get_or_assign(&Ident::from(allocator.alloc_str(specifier))) + }, + ); } // Compile NgModule and generate all definitions as external property assignments @@ -5575,32 +5607,33 @@ fn scope_dependencies<'a>( Some(dependencies) } -/// The partial `ɵɵngDeclareNgModule` imports of the NgModule `class`, with -/// each `ModuleWithProviders` entry listed as its NgModule (see -/// `ng_module::module_with_providers`), or `None` to list them as before. -/// With cross-file analysis, a method or constant of another file is read -/// too, and its NgModule is named through a namespace import, like -/// `ɵɵsetComponentScope` names classes. +/// Read the `declarations`, `imports` and `exports` of the NgModule +/// `class` like ngtsc (see `ng_module::module_with_providers`), and set +/// whether its lists are wrapped in `() => [...]`. With a `registry` +/// (partial mode), the declaration lists what they give. With cross-file +/// analysis, a name of another file is read too, and a class found there +/// is named through a namespace import, like `ɵɵsetComponentScope` names +/// classes. Anything unread keeps its old output. #[expect(clippy::too_many_arguments)] -fn partial_module_with_providers_imports<'a>( +fn resolve_ng_module_lists<'a>( allocator: &'a Allocator, program: &Program<'_>, class: &oxc_ast::ast::Class<'_>, - metadata: &crate::ng_module::NgModuleMetadata<'a>, + metadata: &mut crate::ng_module::NgModuleMetadata<'a>, string_consts: &crate::directive::StringConsts<'_>, import_map: &ImportMap<'_>, import_elision: &ImportElisionAnalyzer<'_>, #[cfg(feature = "cross_file_elision")] analyzer: Option<(&CrossFileAnalyzer, &str, &str)>, - registry: &mut NamespaceRegistry<'a>, -) -> Option>> { - use crate::ng_module::module_with_providers::{Binding, partial_imports}; + registry: Option<&mut NamespaceRegistry<'a>>, +) { #[cfg(feature = "cross_file_elision")] - use crate::ng_module::module_with_providers::{NamedClass, ResolveImported}; - // Gives the closure the signature for every lifetime of its entries. + use crate::ng_module::module_with_providers::{Answer, Question, ResolveImported}; + use crate::ng_module::module_with_providers::{Binding, ListKey, read_module_lists}; + // Gives the closure the signature for every lifetime of its question. #[cfg(feature = "cross_file_elision")] fn resolver(f: F) -> F where - F: Fn(&[(Option<&str>, &str, Option<&str>)]) -> Vec>, + F: Fn(&Question<'_>) -> Option, { f } @@ -5612,36 +5645,40 @@ fn partial_module_with_providers_imports<'a>( }; #[cfg(feature = "cross_file_elision")] let resolve = analyzer.map(|(analyzer, path, source)| { - resolver(move |entries| { - use super::metadata::ImportedName; - analyzer - .module_with_providers_imports(std::path::Path::new(path), source, entries) - .into_iter() - .map(|name| { - name.map(|name| match name { - ImportedName::Local(name) => NamedClass::Local(name), - ImportedName::Module { specifier, name } => { - NamedClass::Module { specifier, name } - } - }) - }) - .collect() + resolver(move |question| { + analyzer.read_imported_ref(std::path::Path::new(path), source, question) }) }); #[cfg(feature = "cross_file_elision")] let resolve_imported = resolve.as_ref().map(|resolve| resolve as ResolveImported<'_>); #[cfg(not(feature = "cross_file_elision"))] let resolve_imported = None; - partial_imports( - allocator, + let current = [ + &metadata.bootstrap[..], + &metadata.declarations[..], + &metadata.imports[..], + &metadata.exports[..], + ]; + let Some(lists) = read_module_lists( program, class, string_consts, - &metadata.imports, + current, + metadata.contains_forward_decls, binding, resolve_imported, - |specifier| registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))), - ) + ) else { + return; + }; + metadata.contains_forward_decls = lists.wrap; + let Some(registry) = registry else { return }; + // In the order the declaration lists them, so namespaces are numbered + // like ngtsc's. + let mut namespace_of = + |specifier: &str| registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))); + metadata.partial_declarations = lists.emit(allocator, ListKey::Declarations, &mut namespace_of); + metadata.partial_imports = lists.emit(allocator, ListKey::Imports, &mut namespace_of); + metadata.partial_exports = lists.emit(allocator, ListKey::Exports, &mut namespace_of); } /// `i0.ɵɵsetComponentScope(Cmp, () => [directives], () => [pipes]);` for @@ -5680,48 +5717,63 @@ fn set_component_scope_statement<'a>( ) } -/// Set [`ImportInfo::is_const_enum`] on the named imports a constructor -/// parameter of `program` names as its whole type (`kc: KeyCodes`, after -/// `T | null` narrowing) when `analyzer` finds a `const enum` behind them. -/// Type-only and default imports are left alone: their `type:` is decided -/// without this. +/// The constructor parameter types of `program` (`kc: KeyCodes`, +/// `k.KeyCodes`, `NS.Inner`, after `T | null` narrowing) that start at an +/// import and that `analyzer` follows to a `const enum`, as written. Also +/// sets [`ImportInfo::is_const_enum`] on an import used as the whole type. +/// Type-only imports are left alone: their `type:` is `undefined` anyway. #[cfg(feature = "cross_file_elision")] -fn mark_const_enum_imports<'a>( +fn imported_const_enums<'a>( program: &Program<'a>, import_map: &mut ImportMap<'a>, analyzer: &CrossFileAnalyzer, path: &str, -) { - let mut checked: rustc_hash::FxHashSet> = rustc_hash::FxHashSet::default(); +) -> rustc_hash::FxHashSet>> { + let mut checked: rustc_hash::FxHashSet>> = rustc_hash::FxHashSet::default(); + let mut found = rustc_hash::FxHashSet::default(); for site in crate::util::declared_classes(program) { let Some(ctor) = crate::util::find_constructor(site.class) else { continue }; for param in &ctor.value.params.items { - let Some(name) = param + let Some(type_path) = param .type_annotation .as_ref() .and_then(|annotation| { crate::class_metadata::param_type_reference(&annotation.type_annotation) }) .and_then(|type_ref| crate::class_metadata::entity_path(&type_ref.type_name)) - .and_then(|path| if let [name] = path[..] { Some(name) } else { None }) else { continue; }; - if !checked.insert(name) { + if !checked.insert(type_path.clone()) { continue; } - let Some(info) = import_map.get_mut(&name) else { continue }; - if !info.is_named_import || info.is_type_only || info.is_default_import { + let Some(info) = import_map.get_mut(&type_path[0]) else { continue }; + if info.is_type_only { continue; } - let export_name = info.imported_name.unwrap_or(name); - info.is_const_enum = analyzer.is_const_enum_import( - info.source_module.as_str(), - export_name.as_str(), - std::path::Path::new(path), - ); + // The path in the module: from the export name for a named or + // default import, from the first member for `import * as k`. + let mut export_path: Vec<&str> = Vec::with_capacity(type_path.len()); + if info.is_named_import { + export_path.push(info.imported_name.unwrap_or(type_path[0]).as_str()); + } + export_path.extend(type_path[1..].iter().map(Ident::as_str)); + if export_path.is_empty() + || !analyzer.is_const_enum_path( + info.source_module.as_str(), + &export_path, + std::path::Path::new(path), + ) + { + continue; + } + if type_path.len() == 1 { + info.is_const_enum = true; + } + found.insert(type_path); } } + found } /// The analyzer behind both cross-file features (`cross_file_elision`'s @@ -7075,6 +7127,7 @@ fn compile_component_full<'a>( file_path, source, options.root_dirs.as_deref(), + options.use_case_sensitive_file_names, resolved_resources, ) } else { @@ -7366,8 +7419,12 @@ fn compile_component_full<'a>( // project root, and none for a file outside it. With no root dirs, // keep the path as given. let debug_file_path = match &options.root_dirs { - Some(roots) => super::source_locations::project_relative_path(file_path, roots) - .filter(|path| !path.is_empty()), + Some(roots) => super::source_locations::project_relative_path( + file_path, + roots, + options.use_case_sensitive_file_names, + ) + .filter(|path| !path.is_empty()), None => Some(file_path.to_string()), }; let mut debug_info = R3ClassDebugInfo::new(component_type, metadata.class_name.clone()) diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index 623193204..503c4c8b3 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -485,6 +485,10 @@ pub struct StringConsts<'a> { /// `Rc`-shared so it can borrow the analyzer that produced it. #[cfg(feature = "cross_file_elision")] resolver: Option>, + /// The constructor parameter types, as written (`k.KeyCodes` is + /// `["k", "KeyCodes"]`), whose import the cross-file analysis followed + /// to a `const enum` (see [`Self::is_imported_const_enum`]). + imported_const_enums: rustc_hash::FxHashSet>>, /// `host:` expressions already run through [`evaluate_host_metadata`], /// keyed by the expression's address and the decorator's name: the /// diagnostic pass (`decorator_io_errors`) and the metadata extraction @@ -527,6 +531,7 @@ impl<'a> StringConsts<'a> { local_decls: std::cell::OnceCell::new(), #[cfg(feature = "cross_file_elision")] resolver: None, + imported_const_enums: rustc_hash::FxHashSet::default(), host_values: std::cell::RefCell::new(HashMap::default()), } } @@ -546,6 +551,24 @@ impl<'a> StringConsts<'a> { ) } + /// Record the imported `const enum` types (see + /// [`Self::is_imported_const_enum`]). + #[cfg(feature = "cross_file_elision")] + pub(crate) fn with_imported_const_enums( + mut self, + paths: rustc_hash::FxHashSet>>, + ) -> Self { + self.imported_const_enums = paths; + self + } + + /// Whether the type name `path`, which starts at an import, ends at a + /// `const enum` in the file the import reads (only with + /// `resolve_imported_values`). + pub(crate) fn is_imported_const_enum(&self, path: &[Ident<'a>]) -> bool { + self.imported_const_enums.contains(path) + } + /// The program these consts were collected from, when available. pub(crate) fn program(&self) -> Option<&'a Program<'a>> { self.program @@ -597,6 +620,7 @@ pub fn collect_string_consts<'a>( local_decls: std::cell::OnceCell::new(), #[cfg(feature = "cross_file_elision")] resolver: None, + imported_const_enums: rustc_hash::FxHashSet::default(), host_values: std::cell::RefCell::new(HashMap::default()), }; loop { diff --git a/crates/oxc_angular_compiler/src/ng_module/decorator.rs b/crates/oxc_angular_compiler/src/ng_module/decorator.rs index cc89ae2b3..0d04911a3 100644 --- a/crates/oxc_angular_compiler/src/ng_module/decorator.rs +++ b/crates/oxc_angular_compiler/src/ng_module/decorator.rs @@ -57,11 +57,16 @@ pub struct NgModuleMetadata<'a> { /// See [`Self::declarations_scope_expr`]. pub exports_scope_expr: Option>, - /// The partial `ɵɵngDeclareNgModule` imports with each - /// `ModuleWithProviders` listed as its NgModule, set by the transform - /// when it could read one (see `module_with_providers`). `None` lists - /// [`Self::imports`]. Full mode never reads it. + /// The partial `ɵɵngDeclareNgModule` declarations, read like ngtsc + /// (lists flattened, each `ModuleWithProviders` as its NgModule), set + /// by the transform when it could read an entry (see + /// `module_with_providers`). `None` lists [`Self::declarations`]. Full + /// mode never reads it. + pub partial_declarations: Option>>, + /// See [`Self::partial_declarations`]; `None` lists [`Self::imports`]. pub partial_imports: Option>>, + /// See [`Self::partial_declarations`]; `None` lists [`Self::exports`]. + pub partial_exports: Option>>, /// Providers expression as OutputExpression. pub providers: Option>, @@ -75,7 +80,9 @@ pub struct NgModuleMetadata<'a> { /// Module ID for registration. pub id: Option>, - /// Whether any declarations/imports/exports contain forward references. + /// Whether the lists are wrapped in `() => [...]`. The parser sets it + /// for a `forwardRef`; the transform replaces it with ngtsc's rule (see + /// `module_with_providers`). pub contains_forward_decls: bool, /// Constructor dependencies for factory generation. @@ -98,7 +105,9 @@ impl<'a> NgModuleMetadata<'a> { declarations_scope_expr: None, imports_scope_expr: None, exports_scope_expr: None, + partial_declarations: None, partial_imports: None, + partial_exports: None, providers: None, bootstrap: Vec::new_in(&allocator), schemas: Vec::new_in(&allocator), diff --git a/crates/oxc_angular_compiler/src/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index 3f36d6639..a7ea162cf 100644 --- a/crates/oxc_angular_compiler/src/ng_module/definition.rs +++ b/crates/oxc_angular_compiler/src/ng_module/definition.rs @@ -224,16 +224,26 @@ pub fn generate_full_ng_module_definition<'a>( // scoping at link time if needed. See // `compiler-cli/src/ngtsc/annotations/ng_module/src/handler.ts:971`. // - // Like ngtsc's `resolveTypeList`, the declaration lists the - // NgModule of each `ModuleWithProviders` entry when the transform - // read it. Only here: the full-mode scope keeps the raw list. - if let Some(imports) = &metadata.partial_imports { - r3_metadata.imports = OxcVec::from_iter_in( - imports.iter().map(|import| { - crate::ng_module::R3Reference::value_only(import.clone_in(allocator)) + // Like ngtsc's `resolveTypeList`, the declaration lists what + // the transform read: lists flattened, and the NgModule of each + // `ModuleWithProviders`. Only here: the full-mode scope keeps + // the raw lists. + let resolved = |list: &OxcVec<'a, OutputExpression<'a>>| { + OxcVec::from_iter_in( + list.iter().map(|item| { + crate::ng_module::R3Reference::value_only(item.clone_in(allocator)) }), &allocator, - ); + ) + }; + if let Some(list) = &metadata.partial_declarations { + r3_metadata.declarations = resolved(list); + } + if let Some(list) = &metadata.partial_imports { + r3_metadata.imports = resolved(list); + } + if let Some(list) = &metadata.partial_exports { + r3_metadata.exports = resolved(list); } let fac_definition = compile_declare_factory_for_ng_module( allocator, @@ -353,9 +363,9 @@ fn build_injector_metadata<'a>( // array literal adds its elements (keeping `StoreModule.forRoot(...)` calls // and spreads), any other expression is added as is. A full or partial // ngtsc build filters both lists by what each reference resolves to; the - // transform drops the entries it knows to be directives or pipes from - // these expressions first (`injector_imports.rs`). The runtime ignores - // entries that are neither NgModules nor standalone components. + // transform rewrites these expressions first the same way, where it can + // (`injector_imports.rs`). The runtime ignores entries that are neither + // NgModules nor standalone components. for (raw, names) in [ (&metadata.raw_imports_expr, &metadata.imports), (&metadata.raw_exports_expr, &metadata.exports), diff --git a/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs b/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs index e7e3b3c47..bda08ea3e 100644 --- a/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs +++ b/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs @@ -1,77 +1,156 @@ -//! Which `imports` and `exports` entries of an NgModule go into its `ɵinj`. +//! Which `imports` and `exports` entries of an NgModule go into its `ɵinj`, +//! and how they are written. //! //! ngtsc's `NgModuleDecoratorHandler.resolve` (compiler-cli //! `ngtsc/annotations/ng_module/src/handler.ts`) builds the injector's //! `imports` in full and partial compilation from what each reference -//! resolves to: it leaves out every directive and pipe of `imports`, and -//! every directive, component and pipe of `exports` (`isNgModule`). Only -//! local compilation adds the lists as written, which is what Oxc did. +//! resolves to. Only local compilation adds the lists as written, which is +//! what Oxc did. Each top-level `imports` entry (an array element, a spread +//! by its argument, or the whole value) is flattened into classes, `const` +//! lists and `forwardRef` read through: +//! - an entry holding a `ModuleWithProviders` is written as is; +//! - directives and pipes are left out, and so is a standalone component +//! that brings in no providers (`ExportedProviderStatusResolver +//! .mayExportProviders`, `ngtsc/metadata/src/providers.ts`); +//! - when every class stays, the entry is written as is (a spread by its +//! argument: `...LIST` gives `LIST`), else each class that stays is +//! written by itself (`i1.X`, or the name this file has for it). //! -//! The runtime's `walkProviderTree` skips anything that is neither an -//! NgModule nor a standalone component, so leaving those entries out never -//! changes which providers are registered. Here an entry is left out only -//! when it is known to be one of them; anything unknown stays. +//! `exports` adds each class that is not a directive or pipe of the module's +//! scope, each by itself. //! -//! Two sources say what an entry is: -//! - this file alone ([`InjectorEntries::local_drops`]): an `exports` entry -//! that `declarations` also lists, and a class of this file with an -//! Angular `@Directive`, `@Pipe` or `@Component` decorator; -//! - cross-file analysis, when it is on, which reads imported classes and -//! `const` lists too (`CrossFileAnalyzer::injector_drops`). -//! -//! Left out on purpose: ngtsc also drops a standalone component from -//! `imports` when it brings in no providers (`mayExportProviders`), and -//! rewrites a list that is only partly dropped into the references it keeps. -//! Oxc keeps such entries whole. +//! Oxc does the same when it can read every class an entry names: from this +//! file alone ([`InjectorEntries::local_plans`]), or with cross-file analysis +//! (`CrossFileAnalyzer::injector_plans`). Anything unknown stays as written. +//! An entry also stays as written when a class it keeps is read through a +//! `forwardRef`: written by itself, a class declared later would be read +//! before it exists (ngtsc's output throws there). + +use std::borrow::Cow; use oxc_allocator::{Allocator, Box, Vec as OxcVec}; use oxc_ast::ast::{ - ArrayExpressionElement, Class, Declaration, ExportDefaultDeclarationKind, Expression, - ObjectPropertyKind, Program, PropertyKey, Statement, + ArrayExpressionElement, Class, Declaration, Decorator, ExportDefaultDeclarationKind, + Expression, ObjectPropertyKind, Program, PropertyKey, Statement, TSTypeName, }; +use oxc_str::Ident; +use rustc_hash::FxHashSet; use super::decorator::{NgModuleMetadata, find_ng_module_decorator}; -use crate::directive::{StringConsts, find_angular_class_decorator}; -use crate::output::ast::{LiteralArrayExpr, OutputExpression}; +use super::module_with_providers::{MwpModule, static_method_ng_module, top_level_const_init}; +use crate::component::ImportedName; +use crate::directive::{StringConsts, angular_core_identifier, find_angular_class_decorator}; +use crate::output::ast::{LiteralArrayExpr, OutputExpression, ReadPropExpr, ReadVarExpr}; use crate::util::is_metadata_property; -/// A top-level entry of `imports`, `exports` or `declarations` that names a -/// class or a `const` list: `A`, `ns.A`, `forwardRef(() => A)`, `...LIST`, -/// or the whole value (`imports: LIST`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// How deep `const` lists and imports are followed in this file. +const MAX_DEPTH: u16 = 64; + +/// A top-level entry of `imports`, `exports` or `declarations`: an array +/// element, the argument of a spread, or the whole value (`imports: LIST`). +#[derive(Debug, Clone, Copy)] pub struct InjectorEntry<'s> { - pub namespace: Option<&'s str>, - pub name: &'s str, + pub expr: &'s Expression<'s>, + pub is_spread: bool, } -/// Per top-level entry of `imports` and of `exports`, whether `ɵinj` leaves -/// it out. +/// What `ɵinj` does with a top-level entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EntryPlan { + /// Keep it as written. + Keep, + /// Leave it out. + Drop, + /// Write a spread as its argument: `...LIST` gives `LIST`. + Unwrap, + /// Write the classes it keeps, each by itself. + Replace(Vec), +} + +/// Per top-level entry of `imports` and of `exports`, what `ɵinj` does with +/// it. #[derive(Debug, Default, Clone, PartialEq, Eq)] -pub struct InjectorDrops { - pub imports: Vec, - pub exports: Vec, +pub struct InjectorPlans { + pub imports: Vec, + pub exports: Vec, } -impl InjectorDrops { - /// Leave out what `other` leaves out too. +impl InjectorPlans { + /// Take what `other` knows: its plan wins, unless it keeps the entry as + /// written or this one leaves the entry out. #[cfg_attr(not(feature = "cross_file_elision"), allow(dead_code))] pub(crate) fn merge(&mut self, other: &Self) { for (mine, theirs) in [(&mut self.imports, &other.imports), (&mut self.exports, &other.exports)] { - for (drop, other_drop) in mine.iter_mut().zip(theirs) { - *drop |= *other_drop; + for (plan, other_plan) in mine.iter_mut().zip(theirs) { + if *plan != EntryPlan::Drop && *other_plan != EntryPlan::Keep { + *plan = other_plan.clone(); + } } } } } +/// A class a top-level entry names, with lists flattened. +#[derive(Debug, Clone)] +pub struct ResolvedRef { + /// Whether `ɵinj` keeps it; `None` when that isn't known. + pub kept: Option, + /// How the compiled file writes it, when it can. + pub name: Option, + /// Reached through a `ModuleWithProviders`. + pub is_mwp: bool, + /// Reached through a `forwardRef`. + pub forward: bool, +} + +/// The plan of an `imports` entry naming `refs` (`None`: not known). +pub fn imports_plan(refs: Option<&[ResolvedRef]>, is_spread: bool) -> EntryPlan { + let Some(refs) = refs else { return EntryPlan::Keep }; + if refs.iter().any(|r| r.kept.is_none()) { + return EntryPlan::Keep; + } + // ngtsc writes the expression as is, without the spread. + if refs.iter().any(|r| r.is_mwp) || refs.iter().all(|r| r.kept == Some(true)) { + return if is_spread { EntryPlan::Unwrap } else { EntryPlan::Keep }; + } + replace(refs) +} + +/// The plan of an `exports` entry naming `refs` (`None`: not known). ngtsc +/// writes each class that stays by itself. +pub fn exports_plan(refs: Option<&[ResolvedRef]>) -> EntryPlan { + let Some(refs) = refs else { return EntryPlan::Keep }; + // A `ModuleWithProviders` is a type error here (ngtsc gives its NgModule + // when the list is cast): keep it, with its providers. + if refs.iter().any(|r| r.kept.is_none() || r.is_mwp) { + return EntryPlan::Keep; + } + replace(refs) +} + +/// Each class of `refs` that stays, by itself. +fn replace(refs: &[ResolvedRef]) -> EntryPlan { + let kept: Vec<&ResolvedRef> = refs.iter().filter(|r| r.kept == Some(true)).collect(); + if kept.is_empty() { + return EntryPlan::Drop; + } + if kept.iter().any(|r| r.forward) { + return EntryPlan::Keep; + } + kept.iter() + .map(|r| r.name.clone()) + .collect::>>() + .map_or(EntryPlan::Keep, EntryPlan::Replace) +} + /// One of the `imports` / `exports` / `declarations` lists, split the way /// ngtsc's `analyze` splits `imports`: each array element (a spread by its /// argument), or the whole value when it is not an array literal. #[derive(Debug, Default)] struct List<'s> { - /// `None` for an entry that names no class or list (a call, a literal...). + /// `None` for an array hole. entries: Vec>>, is_array: bool, } @@ -87,7 +166,7 @@ pub struct InjectorEntries<'s> { /// The entries of the Angular `@NgModule` decorator of `class`, or `None` /// when it has none or its argument is not an object literal. pub fn injector_entries<'s>( - class: &'s Class<'_>, + class: &'s Class<'s>, consts: &StringConsts<'_>, ) -> Option> { let decorator = find_ng_module_decorator(&class.decorators, Some(consts))?; @@ -117,54 +196,64 @@ pub fn injector_entries<'s>( "declarations" => &mut entries.declarations, _ => continue, }; - *list = read_list(&property.value, consts); + *list = read_list(&property.value); } Some(entries) } -fn read_list<'s>(value: &'s Expression<'_>, consts: &StringConsts<'_>) -> List<'s> { +fn read_list<'s>(value: &'s Expression<'s>) -> List<'s> { let Expression::ArrayExpression(array) = value else { - return List { entries: vec![entry_ref(value, consts)], is_array: false }; + return List { + entries: vec![Some(InjectorEntry { expr: value, is_spread: false })], + is_array: false, + }; }; let entries = array .elements .iter() .map(|element| match element { - ArrayExpressionElement::SpreadElement(spread) => entry_ref(&spread.argument, consts), + ArrayExpressionElement::SpreadElement(spread) => { + Some(InjectorEntry { expr: &spread.argument, is_spread: true }) + } ArrayExpressionElement::Elision(_) => None, - _ => entry_ref(element.to_expression(), consts), + _ => Some(InjectorEntry { expr: element.to_expression(), is_spread: false }), }) .collect(); List { entries, is_array: true } } -/// The class or list `expr` names: `A`, `ns.A`, or either one in a -/// `forwardRef` arrow imported from `@angular/core` (the evaluator resolves -/// it to the class, as ngtsc's `forwardRefResolver` does). -fn entry_ref<'s>(expr: &'s Expression<'_>, consts: &StringConsts<'_>) -> Option> { +/// The class or list an entry names as written: `A`, `ns.A`, or either one +/// in a `forwardRef` arrow imported from `@angular/core`. +fn entry_name<'s>( + expr: &'s Expression<'s>, + consts: &StringConsts<'_>, +) -> Option<(Option<&'s str>, &'s str)> { match expr { - Expression::Identifier(id) => { - Some(InjectorEntry { namespace: None, name: id.name.as_str() }) - } + Expression::Identifier(id) => Some((None, id.name.as_str())), Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(ns) => Some(InjectorEntry { - namespace: Some(ns.name.as_str()), - name: member.property.name.as_str(), - }), + Expression::Identifier(ns) => { + Some((Some(ns.name.as_str()), member.property.name.as_str())) + } _ => None, }, - Expression::CallExpression(call) => { - let Expression::Identifier(callee) = &call.callee else { return None }; - crate::directive::angular_core_identifier(&callee.name, Some(consts), &["forwardRef"])?; - let Some(Expression::ArrowFunctionExpression(arrow)) = - call.arguments.first().and_then(|arg| arg.as_expression()) - else { - return None; - }; - match arrow.get_expression()? { - inner @ (Expression::Identifier(_) | Expression::StaticMemberExpression(_)) => { - entry_ref(inner, consts) - } + _ => entry_name(forward_ref_target(expr, consts)?, consts), + } +} + +/// What the `forwardRef(() => x)` call `expr` returns, with `forwardRef` +/// imported from `@angular/core`: another function may return anything. +fn forward_ref_target<'s>( + expr: &'s Expression<'s>, + consts: &StringConsts<'_>, +) -> Option<&'s Expression<'s>> { + let Expression::CallExpression(call) = expr else { return None }; + let Expression::Identifier(callee) = call.callee.get_inner_expression() else { return None }; + angular_core_identifier(&callee.name, Some(consts), &["forwardRef"])?; + match call.arguments.first()?.as_expression()?.get_inner_expression() { + Expression::ArrowFunctionExpression(arrow) => arrow.get_expression(), + Expression::FunctionExpression(function) => { + match function.body.as_ref()?.statements.as_slice() { + [Statement::ReturnStatement(ret)] => ret.argument.as_ref(), _ => None, } } @@ -174,17 +263,58 @@ fn entry_ref<'s>(expr: &'s Expression<'_>, consts: &StringConsts<'_>) -> Option< /// What a class of this file is to an NgModule's lists, by its Angular /// decorator. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LocalKind { +#[derive(Clone, Copy)] +enum LocalKind<'s> { DirectiveOrPipe, - Component, + Component(&'s Decorator<'s>), + NgModule(&'s Decorator<'s>), +} + +/// The kind of `class`, when it has exactly one Angular `@Directive`, +/// `@Pipe`, `@Component` or `@NgModule` decorator. +fn local_kind<'s>(class: &'s Class<'s>, consts: &StringConsts<'_>) -> Option> { + let find = |kind: &str| find_angular_class_decorator(&class.decorators, kind, consts); + match (find("Directive"), find("Pipe"), find("Component"), find("NgModule")) { + (Some(_), None, None, None) | (None, Some(_), None, None) => { + Some(LocalKind::DirectiveOrPipe) + } + (None, None, Some(decorator), None) => Some(LocalKind::Component(decorator)), + (None, None, None, Some(decorator)) => Some(LocalKind::NgModule(decorator)), + _ => None, + } +} + +/// The fields of the object literal `decorator` is called with (none for +/// no argument), or `None` when a spread or a computed key could set any. +fn decorator_fields<'s>( + decorator: &'s Decorator<'s>, +) -> Option, &'s Expression<'s>)>> { + let Expression::CallExpression(call) = &decorator.expression else { return None }; + let mut fields = Vec::new(); + let Some(argument) = call.arguments.first() else { return Some(fields) }; + let Expression::ObjectExpression(object) = argument.as_expression()?.get_inner_expression() + else { + return None; + }; + for property in &object.properties { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + fields.push((property.key.static_name()?.into_cow_str()?, &property.value)); + } + Some(fields) } -/// The kind of the top-level class `program` declares as `name`, when it has -/// exactly one Angular `@Directive`, `@Pipe` or `@Component` decorator and -/// no `@NgModule` one. A name the file imports is never a class it declares. -fn local_kind(program: &Program<'_>, consts: &StringConsts<'_>, name: &str) -> Option { - let class = program.body.iter().find_map(|stmt| { +/// The value of the field `key` (the last one wins). +fn field<'s>( + fields: &[(Cow<'s, str>, &'s Expression<'s>)], + key: &str, +) -> Option<&'s Expression<'s>> { + fields.iter().rev().find(|(name, _)| name == key).map(|(_, value)| *value) +} + +/// The top-level class `program` declares as `name`. A name the file +/// imports is never a class it declares. +fn top_level_class<'s>(program: &'s Program<'s>, name: &str) -> Option<&'s Class<'s>> { + program.body.iter().find_map(|stmt| { let class = match stmt { Statement::ClassDeclaration(class) => class, Statement::ExportDeclaration(decl) => match &decl.declaration { @@ -197,16 +327,184 @@ fn local_kind(program: &Program<'_>, consts: &StringConsts<'_>, name: &str) -> O }, _ => return None, }; - class.id.as_ref().is_some_and(|id| id.name == name).then_some(class) - })?; - let has = |kind: &str| find_angular_class_decorator(&class.decorators, kind, consts).is_some(); - if has("NgModule") { - return None; + class.id.as_ref().is_some_and(|id| id.name == name).then_some(&**class) + }) +} + +/// A class of this file an entry reaches. +#[derive(Clone, Copy)] +struct LocalRef<'s> { + class: &'s Class<'s>, + is_mwp: bool, + forward: bool, +} + +/// The classes and `const` lists of the compiled file. Anything that leaves +/// the file is not known. +struct LocalGraph<'s, 'c, 'k> { + program: &'s Program<'s>, + consts: &'c StringConsts<'k>, +} + +impl<'s> LocalGraph<'s, '_, '_> { + /// The classes of this file `expr` names, with `const` lists, a + /// `forwardRef` and a `ModuleWithProviders` read through. `None` when + /// any part of it is not a class of this file. + fn refs(&self, expr: &'s Expression<'s>) -> Option>> { + let mut out = Vec::new(); + self.collect(expr, false, false, &mut Vec::new(), &mut out, 0)?; + Some(out) } - match (has("Directive"), has("Pipe"), has("Component")) { - (true, false, false) | (false, true, false) => Some(LocalKind::DirectiveOrPipe), - (false, false, true) => Some(LocalKind::Component), - _ => None, + + fn collect( + &self, + expr: &'s Expression<'s>, + is_mwp: bool, + forward: bool, + lists: &mut Vec<&'s str>, + out: &mut Vec>, + depth: u16, + ) -> Option<()> { + if depth > MAX_DEPTH { + return None; + } + let depth = depth + 1; + match expr.get_inner_expression() { + Expression::ArrayExpression(array) => { + for element in &array.elements { + let element = match element { + ArrayExpressionElement::SpreadElement(spread) => &spread.argument, + ArrayExpressionElement::Elision(_) => return None, + _ => element.to_expression(), + }; + self.collect(element, is_mwp, forward, lists, out, depth)?; + } + } + Expression::Identifier(id) => { + let name = id.name.as_str(); + if let Some(class) = top_level_class(self.program, name) { + out.push(LocalRef { class, is_mwp, forward }); + } else { + // A list that holds itself has no value. + if lists.contains(&name) { + return None; + } + let init = top_level_const_init(self.program, name)?; + lists.push(name); + let found = self.collect(init, is_mwp, forward, lists, out, depth); + lists.pop(); + found?; + } + } + // `{ ngModule: X, providers }` + Expression::ObjectExpression(object) => { + let module = object.properties.iter().find_map(|property| match property { + ObjectPropertyKind::ObjectProperty(p) + if p.key.static_name().is_some_and(|key| key == "ngModule") => + { + Some(&p.value) + } + _ => None, + })?; + self.collect(module, true, forward, lists, out, depth)?; + } + Expression::CallExpression(call) => { + if let Some(target) = forward_ref_target(expr.get_inner_expression(), self.consts) { + return self.collect(target, is_mwp, true, lists, out, depth); + } + // `X.forRoot()`, with `X` a class of this file. + let Expression::StaticMemberExpression(member) = call.callee.get_inner_expression() + else { + return None; + }; + let Expression::Identifier(holder) = member.object.get_inner_expression() else { + return None; + }; + let holder = top_level_class(self.program, &holder.name)?; + match static_method_ng_module(holder, &member.property.name)? { + MwpModule::Value(value) => { + self.collect(value, true, forward, lists, out, depth)?; + } + MwpModule::Type(TSTypeName::IdentifierReference(id)) => { + let class = top_level_class(self.program, &id.name)?; + out.push(LocalRef { class, is_mwp: true, forward }); + } + _ => return None, + } + } + _ => return None, + } + Some(()) + } + + /// Whether the class may bring in providers, like ngtsc's + /// `mayExportProviders`: a standalone component or an NgModule that + /// reaches an NgModule with a `providers` key (any value). A component's + /// own providers don't count. `None` when a class it reaches can't be + /// read and none is known to bring in providers. + fn may_export_providers(&self, class: &'s Class<'s>) -> Option { + // ngtsc stops at a class it is already looking at, so from the top + // this is whether such a module can be reached. + let mut seen = FxHashSet::default(); + let mut pending = vec![class]; + let mut unknown = false; + while let Some(class) = pending.pop() { + if !seen.insert(class.span.start) { + continue; + } + let (decorator, is_component) = match local_kind(class, self.consts) { + Some(LocalKind::DirectiveOrPipe) => continue, + Some(LocalKind::Component(decorator)) => (decorator, true), + Some(LocalKind::NgModule(decorator)) => (decorator, false), + None => { + unknown = true; + continue; + } + }; + let Some(fields) = decorator_fields(decorator) else { + unknown = true; + continue; + }; + if is_component { + // A component that is not standalone is an error in + // `imports`: keep it. + match field(&fields, "standalone").map(Expression::get_inner_expression) { + None => {} + Some(Expression::BooleanLiteral(b)) if b.value => {} + Some(Expression::BooleanLiteral(_)) => return Some(true), + Some(_) => { + unknown = true; + continue; + } + } + } else if field(&fields, "providers").is_some() { + return Some(true); + } + let Some(imports) = field(&fields, "imports") else { continue }; + let Some(refs) = self.refs(imports) else { + unknown = true; + continue; + }; + pending.extend(refs.iter().map(|r| r.class)); + } + (!unknown).then_some(false) + } + + /// `refs` as `ɵinj` sees them, `kept` telling what stays of a class + /// whose kind is known. + fn resolved( + &self, + refs: &[LocalRef<'s>], + kept: impl Fn(&LocalRef<'s>, LocalKind<'s>) -> Option, + ) -> Vec { + refs.iter() + .map(|r| ResolvedRef { + kept: local_kind(r.class, self.consts).and_then(|kind| kept(r, kind)), + name: r.class.id.as_ref().map(|id| ImportedName::Local(id.name.to_string())), + is_mwp: r.is_mwp, + forward: r.forward, + }) + .collect() } } @@ -223,63 +521,91 @@ impl<'s> InjectorEntries<'s> { &self.exports.entries } - /// What this file alone shows `ɵinj` leaves out: - /// - an `exports` entry `declarations` also lists: only directives, - /// components and pipes can be declared; - /// - an entry naming a class of this file with a `@Directive` or `@Pipe` - /// decorator; - /// - an `exports` entry naming a class of this file with a `@Component` - /// decorator that `imports` (where it stays) or `declarations` also - /// lists, as ngtsc requires of an exported class. - pub(crate) fn local_drops( + /// What this file alone shows `ɵinj` does with each entry (see the + /// module doc), reading the classes and `const` lists `program` + /// declares. In `exports`, an entry `declarations` also lists is left + /// out (only directives, components and pipes can be declared), and so + /// is a component that `imports` or `declarations` also name, as ngtsc + /// requires of an exported class. Another component stays, as its + /// providers may be needed. + pub(crate) fn local_plans( &self, - program: &Program<'_>, + program: &'s Program<'s>, consts: &StringConsts<'_>, - ) -> InjectorDrops { - let listed_in = |list: &List<'s>, entry: &InjectorEntry<'s>| { - list.entries.iter().flatten().any(|other| other == entry) - }; - let kind = |entry: &InjectorEntry<'s>| match entry.namespace { - None => local_kind(program, consts, entry.name), - Some(_) => None, - }; + ) -> InjectorPlans { + let graph = LocalGraph { program, consts }; + let refs = |entry: &Option>| graph.refs(entry.as_ref()?.expr); let imports = self .imports .entries .iter() .map(|entry| { - entry.is_some_and(|entry| kind(&entry) == Some(LocalKind::DirectiveOrPipe)) + let resolved = refs(entry).map(|refs| { + graph.resolved(&refs, |r, kind| match kind { + LocalKind::DirectiveOrPipe => Some(false), + LocalKind::Component(_) => graph.may_export_providers(r.class), + LocalKind::NgModule(_) => Some(true), + }) + }); + imports_plan(resolved.as_deref(), entry.is_some_and(|entry| entry.is_spread)) }) .collect(); + // The classes of the module's compilation scope this file shows. + let in_scope: FxHashSet = if self.exports.entries.is_empty() { + FxHashSet::default() + } else { + let lists = self.declarations.entries.iter().chain(&self.imports.entries); + lists.filter_map(refs).flatten().map(|r| r.class.span.start).collect() + }; + let name = |entry: &Option>| entry_name(entry.as_ref()?.expr, consts); let exports = self .exports .entries .iter() .map(|entry| { - entry.is_some_and(|entry| { - listed_in(&self.declarations, &entry) - || match kind(&entry) { - Some(LocalKind::DirectiveOrPipe) => true, - Some(LocalKind::Component) => listed_in(&self.imports, &entry), - None => false, - } - }) + if name(entry).is_some_and(|written| { + self.declarations.entries.iter().any(|other| name(other) == Some(written)) + }) { + return EntryPlan::Drop; + } + let resolved = refs(entry).map(|refs| { + graph.resolved(&refs, |r, kind| match kind { + LocalKind::DirectiveOrPipe => Some(false), + LocalKind::Component(_) => Some(!in_scope.contains(&r.class.span.start)), + LocalKind::NgModule(_) => Some(true), + }) + }); + exports_plan(resolved.as_deref()) }) .collect(); - InjectorDrops { imports, exports } + InjectorPlans { imports, exports } } - /// Leave the entries `drops` marks out of the `ɵinj` imports `metadata` - /// builds. A list whose converted expression doesn't line up with the - /// entries read here is kept as is. + /// Apply `plans` to the `ɵinj` imports `metadata` builds. `namespace` + /// gives the namespace import of a module specifier. A list whose + /// converted expression doesn't line up with the entries read here is + /// kept as is. pub(crate) fn apply<'a>( &self, allocator: &'a Allocator, metadata: &mut NgModuleMetadata<'a>, - drops: &InjectorDrops, + plans: &InjectorPlans, + namespace: &mut dyn FnMut(&str) -> Ident<'a>, ) { - apply_list(allocator, &mut metadata.raw_imports_expr, &self.imports, &drops.imports); - apply_list(allocator, &mut metadata.raw_exports_expr, &self.exports, &drops.exports); + apply_list( + allocator, + &mut metadata.raw_imports_expr, + &self.imports, + &plans.imports, + namespace, + ); + apply_list( + allocator, + &mut metadata.raw_exports_expr, + &self.exports, + &plans.exports, + namespace, + ); } } @@ -287,25 +613,72 @@ fn apply_list<'a>( allocator: &'a Allocator, raw: &mut Option>, list: &List<'_>, - drops: &[bool], + plans: &[EntryPlan], + namespace: &mut dyn FnMut(&str) -> Ident<'a>, ) { - if drops.len() != list.entries.len() || !drops.contains(&true) { + if plans.len() != list.entries.len() || plans.iter().all(|plan| *plan == EntryPlan::Keep) { return; } + let mut write = |names: &[ImportedName], out: &mut OxcVec<'a, OutputExpression<'a>>| { + for name in names { + out.push(name_expr(allocator, name, namespace)); + } + }; match raw { Some(OutputExpression::LiteralArray(array)) - if list.is_array && array.entries.len() == drops.len() => + if list.is_array && array.entries.len() == plans.len() => { - let mut drop = drops.iter(); - array.entries.retain(|_| drop.next() == Some(&false)); + let entries = std::mem::replace(&mut array.entries, OxcVec::new_in(&allocator)); + for (entry, plan) in entries.into_iter().zip(plans) { + match plan { + EntryPlan::Keep => array.entries.push(entry), + EntryPlan::Drop => {} + EntryPlan::Unwrap => match entry { + OutputExpression::SpreadElement(spread) => { + array.entries.push(spread.unbox().expr.unbox()); + } + entry => array.entries.push(entry), + }, + EntryPlan::Replace(names) => write(names, &mut array.entries), + } + } } - // `imports: LIST`, all of whose classes are left out. + // `imports: LIST`, written as its classes. Some(expr) if !list.is_array && !matches!(expr, OutputExpression::LiteralArray(_)) => { + let mut entries = OxcVec::new_in(&allocator); + match &plans[0] { + EntryPlan::Drop => {} + EntryPlan::Replace(names) => write(names, &mut entries), + EntryPlan::Keep | EntryPlan::Unwrap => return, + } *expr = OutputExpression::LiteralArray(Box::new_in( - LiteralArrayExpr { entries: OxcVec::new_in(&allocator), source_span: None }, + LiteralArrayExpr { entries, source_span: None }, &allocator, )); } _ => {} } } + +/// `Name`, or `iN.Name` for a class of another module. +fn name_expr<'a>( + allocator: &'a Allocator, + name: &ImportedName, + namespace: &mut dyn FnMut(&str) -> Ident<'a>, +) -> OutputExpression<'a> { + let var = |name: Ident<'a>| { + OutputExpression::ReadVar(Box::new_in(ReadVarExpr { name, source_span: None }, &allocator)) + }; + match name { + ImportedName::Local(name) => var(Ident::from(allocator.alloc_str(name))), + ImportedName::Module { specifier, name } => OutputExpression::ReadProp(Box::new_in( + ReadPropExpr { + receiver: Box::new_in(var(namespace(specifier)), &allocator), + name: Ident::from(allocator.alloc_str(name)), + optional: false, + source_span: None, + }, + &allocator, + )), + } +} diff --git a/crates/oxc_angular_compiler/src/ng_module/mod.rs b/crates/oxc_angular_compiler/src/ng_module/mod.rs index 8d0d5e43f..94f3fc8b4 100644 --- a/crates/oxc_angular_compiler/src/ng_module/mod.rs +++ b/crates/oxc_angular_compiler/src/ng_module/mod.rs @@ -28,7 +28,9 @@ pub use definition::{ }; pub(crate) use injector_imports::injector_entries; #[cfg(feature = "cross_file_elision")] -pub(crate) use injector_imports::{InjectorDrops, InjectorEntry}; +pub(crate) use injector_imports::{ + EntryPlan, InjectorEntry, InjectorPlans, ResolvedRef, exports_plan, imports_plan, +}; pub use metadata::{ R3NgModuleMetadata, R3NgModuleMetadataBuilder, R3Reference, R3SelectorScopeMode, }; diff --git a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs index 90667f9e6..868e27646 100644 --- a/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs +++ b/crates/oxc_angular_compiler/src/ng_module/module_with_providers.rs @@ -1,26 +1,30 @@ -//! `ModuleWithProviders` entries in a partial `ɵɵngDeclareNgModule`'s -//! `imports`. +//! The `declarations`, `imports` and `exports` of a partial +//! `ɵɵngDeclareNgModule`, read like ngtsc's `resolveTypeList`. //! -//! ngtsc evaluates each `imports` entry, and an entry that gives a -//! `ModuleWithProviders` (`Dep.forRoot()`, or a constant holding one) is -//! listed as the NgModule it names, not as written: `imports: [mwp, -//! Holder.forRoot()]` becomes `imports: [DepModule, DepModule]`. See +//! ngtsc evaluates each list: nested arrays, spreads and `const` lists are +//! flattened, and an entry that gives a `ModuleWithProviders` +//! (`Dep.forRoot()`, or a constant holding one) is listed as the NgModule it +//! names, not as written: `imports: [mwp, Holder.forRoot()]` becomes +//! `imports: [DepModule, DepModule]`. See //! `NgModuleDecoratorHandler.resolveTypeList` and //! `createModuleWithProvidersResolver` in //! `packages/compiler-cli/src/ngtsc/annotations/ng_module/src/`. //! //! Oxc used to list the class a method is called on (`Holder`), which is -//! not an NgModule at all when another class holds the method. When a JIT -//! linker inlines that list into `ɵɵdefineNgModule`, the runtime gets a -//! class that is no module and a `ModuleWithProviders` object. +//! not an NgModule at all when another class holds the method, and to drop +//! spreads and namespace members. When a JIT linker inlines that list into +//! `ɵɵdefineNgModule`, the runtime gets a class that is no module and a +//! `ModuleWithProviders` object. //! -//! Only the partial declaration changes: the injector keeps the raw +//! Only the partial declaration lists change: the injector keeps the raw //! entries (it needs their providers), and the full-mode -//! `ɵɵsetNgModuleScope` keeps the raw list, which the runtime unwraps. +//! `ɵɵsetNgModuleScope` keeps the raw list, which the runtime unwraps. In +//! both modes, the lists are wrapped in `() => [...]` like ngtsc does: when +//! one names a class this file declares after the NgModule. //! -//! This file reads what one file can tell. With cross-file analysis, a -//! method or constant of another file is read by -//! `CrossFileAnalyzer::module_with_providers_imports`. +//! A top-level entry that can't be read keeps its old output. This file +//! reads what one file can tell. With cross-file analysis, a name of +//! another file is read by `CrossFileAnalyzer::read_imported_ref`. use oxc_ast::ast::{ ArrayExpressionElement, Class, ClassElement, Declaration, Expression, MethodDefinitionKind, @@ -187,7 +191,7 @@ fn return_type_module<'b, 'a>( /// `expr` without what ngtsc's evaluator looks through: parentheses, `as` /// and `!` (`visitExpression`). It can't evaluate `satisfies`, `x` or /// `x`. -fn evaluated<'b, 'a>(mut expr: &'b Expression<'a>) -> &'b Expression<'a> { +pub fn evaluated<'b, 'a>(mut expr: &'b Expression<'a>) -> &'b Expression<'a> { loop { expr = match expr { Expression::ParenthesizedExpression(e) => &e.expression, @@ -310,117 +314,6 @@ fn top_level_class<'b, 'a>(program: &'b Program<'a>, name: &str) -> Option<&'b C }) } -/// One `imports` entry, in the order and shape the decorator parser lists -/// them (`extract_reference_array`): its spreads and other expressions are -/// left out. -pub struct ImportEntry<'b> { - /// The name the partial declaration lists for it today. - pub name: &'b str, - /// The `method` of `name.method(...)`, `None` for a plain name or a - /// `forwardRef`. - pub method: Option<&'b str>, - /// Whether it is written as `forwardRef(() => name)`. - pub forward_ref: bool, -} - -/// The `imports` entries of the `@NgModule` of `class`, `None` when its -/// `imports` is not an array literal. -pub fn ng_module_import_entries<'b>( - class: &'b Class<'_>, - consts: &StringConsts<'_>, -) -> Option>> { - let decorator = find_ng_module_decorator(&class.decorators, Some(consts))?; - let Expression::CallExpression(call) = &decorator.expression else { return None }; - let Some(oxc_ast::ast::Argument::ObjectExpression(config)) = call.arguments.first() else { - return None; - }; - // The parser keeps the last `imports` key. - let imports = config.properties.iter().rev().find_map(|property| { - let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; - let key = match &property.key { - PropertyKey::StaticIdentifier(id) => id.name.as_str(), - PropertyKey::StringLiteral(lit) => lit.value.as_str()?, - _ => return None, - }; - (crate::util::is_metadata_property(property) && key == "imports").then_some(&property.value) - })?; - let Expression::ArrayExpression(array) = imports else { return None }; - let mut entries = Vec::new(); - for element in &array.elements { - match element { - ArrayExpressionElement::Identifier(id) => { - entries.push(ImportEntry { - name: id.name.as_str(), - method: None, - forward_ref: false, - }); - } - ArrayExpressionElement::CallExpression(call) => match &call.callee { - Expression::Identifier(id) if id.name == "forwardRef" => { - if let Some(oxc_ast::ast::Argument::ArrowFunctionExpression(arrow)) = - call.arguments.first() - && let Some(Expression::Identifier(inner)) = arrow.get_expression() - { - entries.push(ImportEntry { - name: inner.name.as_str(), - method: None, - forward_ref: true, - }); - } - } - Expression::StaticMemberExpression(member) => { - if let Expression::Identifier(id) = &member.object { - entries.push(ImportEntry { - name: id.name.as_str(), - method: Some(member.property.name.as_str()), - forward_ref: false, - }); - } - } - _ => {} - }, - _ => {} - } - } - Some(entries) -} - -/// The NgModule a `ModuleWithProviders` entry names, when this file alone -/// tells it: the entry is `Class.method(...)` or a top-level `const` of one -/// (or of `{ ngModule: T }`), `Class` is declared in this file, and `T` is -/// a class this file declares before `module_class`, or a binding whose -/// import the output keeps anyway (`is_kept_import`). ngtsc then names `T` -/// as this file writes it (`DepModule`, not `i1.DepModule`). -/// -/// A class declared later would be read before it exists, and an import -/// only used as a type is removed, so those stay as they were. -pub fn resolve_local_entry<'b>( - program: &'b Program<'_>, - module_class: &Class<'_>, - entry: &ImportEntry<'_>, - is_kept_import: impl Fn(&str) -> bool, -) -> Option<&'b str> { - if entry.forward_ref { - return None; - } - let module = match entry.method { - Some(method) => method_module(program, entry.name, method)?, - None => match module_with_providers_init(top_level_const_init(program, entry.name)?)? { - MwpInit::Call { namespace: None, class, method } => { - method_module(program, class, method)? - } - MwpInit::Call { namespace: Some(_), .. } => return None, - MwpInit::Module(value) => match value { - Expression::Identifier(id) => id.name.as_str(), - _ => return None, - }, - }, - }; - let declared_before = top_level_class(program, module) - .is_some_and(|class| class.span.end <= module_class.span.start); - (declared_before || is_kept_import(module)).then_some(module) -} - /// The name `program` writes the NgModule of `class.method(...)` by, when /// `class` is declared there. fn method_module<'b>(program: &'b Program<'_>, class: &str, method: &str) -> Option<&'b str> { @@ -480,123 +373,464 @@ pub enum NamedClass { Module { specifier: String, name: String }, } -/// Cross-file analysis of `(namespace, name, method)` entries: the -/// NgModule each one names, if it can tell. -pub type ResolveImported<'r> = - &'r dyn Fn(&[(Option<&str>, &str, Option<&str>)]) -> std::vec::Vec>; - -/// The partial `ɵɵngDeclareNgModule` imports of the `@NgModule` `class` -/// with each `ModuleWithProviders` entry listed as its NgModule, or `None` -/// when none was read (or the entries don't line up with `current`, the -/// names listed today). An entry this file can't settle goes to -/// `resolve_imported` (cross-file analysis: `(namespace, name, method)` -/// entries), and an entry no one settles keeps today's name. -#[expect(clippy::too_many_arguments)] -pub fn partial_imports<'a>( - allocator: &'a oxc_allocator::Allocator, +/// A question to cross-file analysis about a binding the compiled file +/// imports: what `name` (or `namespace.name`) is, or what +/// `name.method(...)` gives. +#[cfg_attr(not(feature = "cross_file_elision"), expect(dead_code))] +pub struct Question<'s> { + pub namespace: Option<&'s str>, + pub name: &'s str, + pub method: Option<&'s str>, + /// Written `...name`: only a `const` array counts. + pub spread: bool, +} + +/// What cross-file analysis read for a [`Question`]. `unready` is set when +/// a class may not be defined yet as the NgModule is: its file, or the one +/// it is imported through, loads this one back. +#[cfg_attr(not(feature = "cross_file_elision"), expect(dead_code))] +pub enum Answer { + /// A class the file imports by this name: it is listed as written. + AsWritten { unready: bool }, + /// The classes it gives, a `const` list flattened and each + /// `ModuleWithProviders` as its NgModule. `forward_ref` is set when + /// the list writes a `forwardRef`. + Classes { classes: Vec, unready: bool, forward_ref: bool }, +} + +/// Cross-file analysis of a [`Question`]; `None` when it can't tell. +pub type ResolveImported<'r> = &'r dyn Fn(&Question<'_>) -> Option; + +/// A class a list entry names. +enum Ref { + /// By a name of the compiled file. + Local(String), + /// The export `name` of `specifier`, through a namespace import. + Module { specifier: String, name: String }, +} + +/// A top-level entry of a list. +struct Entry { + /// The name the decorator parser lists for it (`extract_reference_array`). + fallback: Option, + /// The classes it gives, `None` when unsure. + refs: Option>, +} + +/// A list of an `@NgModule` the partial declaration names. +#[derive(Clone, Copy)] +pub enum ListKey { + Declarations, + Imports, + Exports, +} + +/// The `declarations`, `imports` and `exports` of an `@NgModule`, read like +/// ngtsc's `resolveTypeList`: nested arrays and `const` lists flattened, +/// each `ModuleWithProviders` listed as its NgModule, duplicates kept. +pub struct ModuleLists { + lists: [Vec; 3], + /// Whether the lists must be read lazily (`() => [...]`): one names a + /// class of this file declared after the NgModule (ngtsc's + /// `isExpressionForwardReference`), or a `forwardRef` target that may + /// not be initialized yet. + pub wrap: bool, +} + +/// How deep `const` lists and nested arrays are followed. +const MAX_DEPTH: u8 = 8; + +/// Read the lists of the `@NgModule` `class`. `current` is what the +/// decorator parser read for `bootstrap`, `declarations`, `imports` and +/// `exports`: `None` when the entries don't line up with it. `bootstrap` +/// is only read for `wrap`. `forward_refs` is whether the parser saw a +/// `forwardRef`: it wrapped every list then, so the wrap stays unless every +/// entry is known to be defined. +pub fn read_module_lists( program: &Program<'_>, class: &Class<'_>, consts: &StringConsts<'_>, - current: &[oxc_str::Ident<'a>], + current: [&[oxc_str::Ident<'_>]; 4], + forward_refs: bool, binding: impl Fn(&str) -> Binding, resolve_imported: Option>, - mut namespace_of: impl FnMut(&str) -> oxc_str::Ident<'a>, -) -> Option>> { - use crate::output::ast::{OutputExpression, ReadPropExpr, ReadVarExpr}; - use oxc_allocator::Box; - - let entries = ng_module_import_entries(class, consts)?; - if entries.len() != current.len() - || entries.iter().zip(current).any(|(entry, name)| entry.name != name.as_str()) - { +) -> Option { + let decorator = find_ng_module_decorator(&class.decorators, Some(consts))?; + let Expression::CallExpression(call) = &decorator.expression else { return None }; + let Some(oxc_ast::ast::Argument::ObjectExpression(config)) = call.arguments.first() else { return None; - } - let read_var = |name: oxc_str::Ident<'a>| { - OutputExpression::ReadVar(Box::new_in(ReadVarExpr { name, source_span: None }, &allocator)) }; - let mut resolved: std::vec::Vec>> = entries - .iter() - .map(|entry| { - let is_kept = |name: &str| matches!(binding(name), Binding::Import { kept: true }); - resolve_local_entry(program, class, entry, is_kept) - .map(|name| read_var(oxc_str::Ident::from(allocator.alloc_str(name)))) + // The parser keeps the last key. + let field = |name: &str| { + config.properties.iter().rev().find_map(|property| { + let ObjectPropertyKind::ObjectProperty(property) = property else { return None }; + let key = match &property.key { + PropertyKey::StaticIdentifier(id) => id.name.as_str(), + PropertyKey::StringLiteral(lit) => lit.value.as_str()?, + _ => return None, + }; + (crate::util::is_metadata_property(property) && key == name).then_some(&property.value) }) - .collect(); - - // The entries whose method or constant may be in another file. - if let Some(resolve_imported) = resolve_imported { - let is_import = |name: &str| matches!(binding(name), Binding::Import { .. }); - let mut asked = std::vec::Vec::new(); - let mut questions = std::vec::Vec::new(); - for (index, entry) in entries.iter().enumerate() { - if resolved[index].is_some() || entry.forward_ref { + }; + let mut walker = + Walker { program, module: class, binding, resolve_imported, wrap: false, unsure: false }; + let mut lists = ["bootstrap", "declarations", "imports", "exports"] + .into_iter() + .zip(current) + .map(|(key, current)| { + let entries = field(key).map(|value| walker.entries(value)).unwrap_or_default(); + let fallbacks = entries.iter().filter_map(|entry| entry.fallback.as_deref()); + fallbacks.eq(current.iter().map(oxc_str::Ident::as_str)).then_some(entries) + }) + .collect::>>()? + .into_iter(); + lists.next(); // bootstrap + let mut next = || lists.next().unwrap_or_default(); + let wrap = walker.wrap || (forward_refs && walker.unsure); + Some(ModuleLists { lists: [next(), next(), next()], wrap }) +} + +impl ModuleLists { + /// The partial declaration's `key` list, `None` to list it as before. + /// `namespace_of` gives the namespace import of a module specifier; it + /// is called in the order the classes are listed. + pub fn emit<'a>( + &self, + allocator: &'a oxc_allocator::Allocator, + key: ListKey, + mut namespace_of: impl FnMut(&str) -> oxc_str::Ident<'a>, + ) -> Option>> { + use crate::output::ast::{OutputExpression, ReadPropExpr, ReadVarExpr}; + use oxc_allocator::Box; + + let entries = &self.lists[key as usize]; + if entries.iter().all(|entry| entry.refs.is_none()) { + return None; + } + let name = |name: &str| oxc_str::Ident::from(allocator.alloc_str(name)); + let read_var = |name: oxc_str::Ident<'a>| { + OutputExpression::ReadVar(Box::new_in( + ReadVarExpr { name, source_span: None }, + &allocator, + )) + }; + let mut list = oxc_allocator::Vec::new_in(&allocator); + for entry in entries { + let Some(refs) = &entry.refs else { + list.extend(entry.fallback.as_deref().map(|fallback| read_var(name(fallback)))); continue; - } - let question = match entry.method { - Some(method) if is_import(entry.name) => (None, entry.name, Some(method)), - Some(_) => continue, - None if is_import(entry.name) => (None, entry.name, None), - None => { - let Some(MwpInit::Call { namespace, class, method }) = - top_level_const_init(program, entry.name) - .and_then(module_with_providers_init) - else { - continue; - }; - let imported = match namespace { - Some(ns) => matches!(binding(ns), Binding::Namespace), - None => is_import(class), - }; - if !imported { - continue; - } - (namespace, class, Some(method)) - } }; - asked.push(index); - questions.push(question); - } - if !questions.is_empty() { - for (index, answer) in asked.into_iter().zip(resolve_imported(&questions)) { - resolved[index] = answer.and_then(|module| match module { - // As for a same-file entry: a class declared after this - // NgModule isn't initialized yet when its `ɵmod` is (ngtsc - // wraps the list in `() => [...]` then), and an import - // must stay. Otherwise today's name is kept. - NamedClass::Local(name) => { - let declared_before = top_level_class(program, &name) - .is_some_and(|module| module.span.end <= class.span.start); - let is_kept = matches!(binding(&name), Binding::Import { kept: true }); - (declared_before || is_kept) - .then(|| read_var(oxc_str::Ident::from(allocator.alloc_str(&name)))) - } - NamedClass::Module { specifier, name } => { - Some(OutputExpression::ReadProp(Box::new_in( + for class in refs { + list.push(match class { + Ref::Local(local) => read_var(name(local)), + Ref::Module { specifier, name: export } => { + OutputExpression::ReadProp(Box::new_in( ReadPropExpr { receiver: Box::new_in( - read_var(namespace_of(&specifier)), + read_var(namespace_of(specifier)), &allocator, ), - name: oxc_str::Ident::from(allocator.alloc_str(&name)), + name: name(export), optional: false, source_span: None, }, &allocator, - ))) + )) } }); } } + Some(list) } +} - if resolved.iter().all(Option::is_none) { - return None; +/// The name the decorator parser lists for a top-level entry (see +/// `extract_reference_array`). +fn fallback_name(element: &ArrayExpressionElement<'_>) -> Option { + let name = match element { + ArrayExpressionElement::Identifier(id) => &id.name, + ArrayExpressionElement::CallExpression(call) => match &call.callee { + Expression::Identifier(id) if id.name == "forwardRef" => { + let Some(oxc_ast::ast::Argument::ArrowFunctionExpression(arrow)) = + call.arguments.first() + else { + return None; + }; + let Some(Expression::Identifier(inner)) = arrow.get_expression() else { + return None; + }; + &inner.name + } + Expression::StaticMemberExpression(member) => match &member.object { + Expression::Identifier(id) => &id.name, + _ => return None, + }, + _ => return None, + }, + _ => return None, + }; + Some(name.to_string()) +} + +/// Reads list entries in the compiled file. +struct Walker<'p, 'a, 'r, B> { + program: &'p Program<'a>, + module: &'p Class<'a>, + binding: B, + resolve_imported: Option>, + wrap: bool, + /// Set when an entry is unread, or may not be defined yet. The + /// decorator parser wraps every list once one writes a `forwardRef`, + /// so the wrap stays then. + unsure: bool, +} + +impl Binding> Walker<'_, '_, '_, B> { + /// The top-level entries of a list. A value that is not an array + /// literal (`imports: MODS`) is one entry, which lists nothing today. + fn entries(&mut self, value: &Expression<'_>) -> std::vec::Vec { + let Expression::ArrayExpression(array) = value else { + let refs = self.spread(value, 0); + self.unsure |= refs.is_none(); + return vec![Entry { fallback: None, refs }]; + }; + array + .elements + .iter() + .map(|element| { + let refs = self.element(element, 0); + self.unsure |= refs.is_none(); + Entry { fallback: fallback_name(element), refs } + }) + .collect() } - Some(oxc_allocator::Vec::from_iter_in( - resolved + + fn element( + &mut self, + element: &ArrayExpressionElement<'_>, + depth: u8, + ) -> Option> { + match element { + ArrayExpressionElement::SpreadElement(spread) => self.spread(&spread.argument, depth), + ArrayExpressionElement::Elision(_) => None, + _ => self.value(element.to_expression(), depth), + } + } + + /// Every element of `array` must resolve. All are read, so a later + /// class is seen in each. + fn array( + &mut self, + array: &oxc_ast::ast::ArrayExpression<'_>, + depth: u8, + ) -> Option> { + let mut refs = Some(std::vec::Vec::new()); + for element in &array.elements { + match (self.element(element, depth + 1), &mut refs) { + (Some(read), Some(refs)) => refs.extend(read), + _ => refs = None, + } + } + refs + } + + /// `...expr`: an array literal or a `const` array. + fn spread(&mut self, expr: &Expression<'_>, depth: u8) -> Option> { + if depth > MAX_DEPTH { + return None; + } + match evaluated(expr) { + Expression::ArrayExpression(array) => self.array(array, depth), + Expression::Identifier(id) => match top_level_const_init(self.program, &id.name) { + Some(init) => match evaluated(init) { + Expression::ArrayExpression(array) => self.array(array, depth + 1), + _ => None, + }, + None => self.ask_import(&id.name, true), + }, + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(ns) = evaluated(&member.object) else { return None }; + self.ask_namespace(&ns.name, &member.property.name, None, true) + } + _ => None, + } + } + + fn value(&mut self, expr: &Expression<'_>, depth: u8) -> Option> { + if depth > MAX_DEPTH { + return None; + } + match evaluated(expr) { + Expression::ArrayExpression(array) => self.array(array, depth), + Expression::Identifier(id) => self.name(&id.name, depth), + Expression::StaticMemberExpression(member) => { + let Expression::Identifier(ns) = evaluated(&member.object) else { return None }; + self.ask_namespace(&ns.name, &member.property.name, None, false) + } + Expression::CallExpression(call) => match &call.callee { + Expression::Identifier(id) if id.name == "forwardRef" => self.forward_ref(call), + _ => { + let (namespace, class, method) = static_call(call)?; + self.mwp_call(namespace, class, method) + } + }, + Expression::ObjectExpression(object) => match object_ng_module(object)? { + ObjectModule::Value(Expression::Identifier(id)) => self.class_name(&id.name), + _ => None, + }, + _ => None, + } + } + + /// A name: a class, a `const` (a list, an alias, a + /// `ModuleWithProviders`), or an import. + fn name(&mut self, name: &str, depth: u8) -> Option> { + if depth > MAX_DEPTH { + return None; + } + if self.local_class(name) { + return Some(vec![Ref::Local(name.into())]); + } + let Some(init) = top_level_const_init(self.program, name) else { + return self.ask_import(name, false); + }; + match evaluated(init) { + Expression::ArrayExpression(array) => self.array(array, depth + 1), + Expression::Identifier(alias) => self.name(&alias.name, depth + 1), + init => match module_with_providers_init(init)? { + MwpInit::Call { namespace, class, method } => { + self.mwp_call(namespace, class, method) + } + MwpInit::Module(Expression::Identifier(id)) => self.class_name(&id.name), + MwpInit::Module(_) => None, + }, + } + } + + /// `class.method(...)` or `namespace.class.method(...)`. + fn mwp_call( + &mut self, + namespace: Option<&str>, + class: &str, + method: &str, + ) -> Option> { + let program = self.program; + match namespace { + None if top_level_class(program, class).is_some() => { + self.class_name(method_module(program, class, method)?) + } + None => { + if !matches!((self.binding)(class), Binding::Import { .. }) { + return None; + } + let question = + Question { namespace: None, name: class, method: Some(method), spread: false }; + let answer = self.ask(&question)?; + self.classes(answer) + } + Some(namespace) => self.ask_namespace(namespace, class, Some(method), false), + } + } + + /// `forwardRef(() => name)`. The wrap stays unless the target is known + /// to be initialized when the NgModule is defined. + fn forward_ref( + &mut self, + call: &oxc_ast::ast::CallExpression<'_>, + ) -> Option> { + let target = match call.arguments.first() { + Some(oxc_ast::ast::Argument::ArrowFunctionExpression(arrow)) => { + arrow.get_expression().map(evaluated) + } + _ => None, + }; + let Some(Expression::Identifier(id)) = target else { + self.wrap = true; + return None; + }; + let name = id.name.as_str(); + if self.local_class(name) { + return Some(vec![Ref::Local(name.into())]); + } + let Binding::Import { kept } = (self.binding)(name) else { + self.wrap = true; + return None; + }; + let question = Question { namespace: None, name, method: None, spread: false }; + match self.ask(&question) { + Some(Answer::AsWritten { unready }) => self.wrap |= unready, + Some(Answer::Classes { .. }) => { + self.wrap = true; + return None; + } + None => self.wrap = true, + } + kept.then(|| vec![Ref::Local(name.into())]) + } + + /// A name this file imports. + fn ask_import(&mut self, name: &str, spread: bool) -> Option> { + let Binding::Import { kept } = (self.binding)(name) else { return None }; + let question = Question { namespace: None, name, method: None, spread }; + match self.ask(&question)? { + Answer::AsWritten { unready } => { + self.unsure |= unready; + (kept && !spread).then(|| vec![Ref::Local(name.into())]) + } + answer @ Answer::Classes { .. } => self.classes(answer), + } + } + + /// `namespace.name`, or `namespace.name.method(...)`. + fn ask_namespace( + &mut self, + namespace: &str, + name: &str, + method: Option<&str>, + spread: bool, + ) -> Option> { + if !matches!((self.binding)(namespace), Binding::Namespace) { + return None; + } + let question = Question { namespace: Some(namespace), name, method, spread }; + let answer = self.ask(&question)?; + self.classes(answer) + } + + fn ask(&self, question: &Question<'_>) -> Option { + (self.resolve_imported?)(question) + } + + fn classes(&mut self, answer: Answer) -> Option> { + let Answer::Classes { classes, unready, forward_ref } = answer else { return None }; + // A `forwardRef` in the list asks for the wrap; see `Walker::unsure` + // for the others. + self.wrap |= forward_ref && unready; + self.unsure |= unready; + classes .into_iter() - .zip(current) - .map(|(resolved, name)| resolved.unwrap_or_else(|| read_var(*name))), - &allocator, - )) + .map(|class| match class { + NamedClass::Local(name) => self.local_class(&name).then_some(Ref::Local(name)), + NamedClass::Module { specifier, name } => Some(Ref::Module { specifier, name }), + }) + .collect() + } + + /// A class this file declares or a kept import, listed by `name`. + fn class_name(&mut self, name: &str) -> Option> { + let listed = self.local_class(name) + || matches!((self.binding)(name), Binding::Import { kept: true }); + listed.then(|| vec![Ref::Local(name.into())]) + } + + /// Whether `name` is a top-level class of this file. One declared after + /// the NgModule isn't initialized when its definition is: the lists + /// are wrapped then, like ngtsc does. + fn local_class(&mut self, name: &str) -> bool { + let Some(class) = top_level_class(self.program, name) else { return false }; + if class.span.start > self.module.span.start { + self.wrap = true; + } + true + } } diff --git a/crates/oxc_angular_compiler/tests/const_enum_ctor_param_test.rs b/crates/oxc_angular_compiler/tests/const_enum_ctor_param_test.rs index 3e4a00ce9..47c6ff98c 100644 --- a/crates/oxc_angular_compiler/tests/const_enum_ctor_param_test.rs +++ b/crates/oxc_angular_compiler/tests/const_enum_ctor_param_test.rs @@ -4,9 +4,12 @@ //! compilation (issue #623). Each expected output was checked against //! `@angular/compiler-cli` 22.2.3 in full and partial mode. //! -//! - same file: `const enum`, `declare const enum`, `ns.E` +//! - same file: `const enum`, `declare const enum`, `ns.E`, and a `const enum` +//! merged with namespaces that hold only types //! - imported: only with `resolve_imported_values`, when the other file can -//! be read and shows a `const enum` +//! be read and shows a `const enum`: named, default and namespace imports +//! (`k.KeyCodes`, `k.NS.Inner`), re-exports, `.d.ts` files and package +//! typings //! - anything oxc can't be sure of keeps today's output, and factories don't //! change @@ -148,19 +151,98 @@ export class Cmp { } #[test] -fn const_enum_merged_with_a_namespace_keeps_its_type() { - // ngtsc gives `undefined` here too, but one file can't be sure what the - // namespace merges with, so the reference stays. +fn const_enum_merged_with_a_type_only_namespace_has_no_type() { + // The merged symbol keeps its `ConstEnum` flag. ngtsc: `undefined` six + // times, then `R1` for a regular enum. + let source = "import { Injectable, Inject } from '@angular/core'; +const enum T1 { A = 1 } +namespace T1 { export type X = 1; } +namespace T2 { export interface I {} } +const enum T2 { A = 1 } +declare const enum D1 { A = 1 } +declare namespace D1 { type X = 1; } +export const enum E1 { A = 1 } +export namespace E1 { export namespace Deep { export type Z = 1; } } +const enum N1 { A = 1 } +namespace N1 { } +export namespace Outer { export const enum M { A = 1 } export namespace M { export type Q = 1; } } +enum R1 { A = 1 } +namespace R1 { export const x = 1; } +@Injectable() +export class Svc { + constructor(@Inject('T1') t1: T1, @Inject('T2') t2: T2, @Inject('D1') d1: D1, @Inject('E1') e1: E1, @Inject('N1') n1: N1, @Inject('OM') om: Outer.M, @Inject('R1') r1: R1) {} +}"; + for mode in MODES { + let code = compile(source, mode); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"T1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"T2\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"D1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"E1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"N1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"OM\"]}]},\ + {type:R1,decorators:[{type:Inject,args:[\"R1\"]}]}]", + "{mode:?}" + ); + } +} + +#[test] +fn const_enum_merged_over_several_namespace_blocks_has_no_type() { + // Two type-only blocks; a block with only a local `import X = A.B`; a + // block of `Outer` that keeps an instantiated `namespace M3` to itself + // (it doesn't merge with the exported `M3`); a dotted `namespace D.B`. + // ngtsc: `undefined` for all four. let source = "import { Injectable, Inject } from '@angular/core'; const enum M { A = 1 } namespace M { export type T = 1; } +namespace M { export interface I {} } +namespace Foo { export namespace Bar { export type Z = 1; } } +const enum M2 { A = 1 } +namespace M2 { import X = Foo.Bar; export type Y = X.Z; } +export namespace Outer { export const enum M3 { A = 1 } } +export namespace Outer { namespace M3 { export const x = 1; } export const y = M3.x; } +const enum D { A = 1 } +namespace D.B { export type X = 1; } @Injectable() -export class Svc { constructor(@Inject('M') m: M) {} }"; +export class Svc { + constructor(@Inject('M') m: M, @Inject('M2') m2: M2, @Inject('M3') m3: Outer.M3, @Inject('D') d: D) {} +}"; for mode in MODES { let code = compile(source, mode); assert_eq!( ctor_params(&code), - "[{type:M,decorators:[{type:Inject,args:[\"M\"]}]}]", + "[{type:undefined,decorators:[{type:Inject,args:[\"M\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"M2\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"M3\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"D\"]}]}]", + "{mode:?}" + ); + } +} + +#[test] +fn const_enum_merged_with_an_instantiated_namespace_keeps_its_type() { + // TS2567: ngtsc writes no output. The reference stays as before. + let source = "import { Injectable, Inject } from '@angular/core'; +const enum V1 { A = 1 } +namespace V1 { export const x = 1; } +declare const enum V2 { A = 1 } +declare namespace V2 { const x: number; } +const enum V3 { A = 1 } +namespace V3 { export const enum I { B = 2 } } +@Injectable() +export class Svc { + constructor(@Inject('V1') v1: V1, @Inject('V2') v2: V2, @Inject('V3') v3: V3) {} +}"; + for mode in MODES { + let code = compile(source, mode); + assert_eq!( + ctor_params(&code), + "[{type:V1,decorators:[{type:Inject,args:[\"V1\"]}]},\ + {type:V2,decorators:[{type:Inject,args:[\"V2\"]}]},\ + {type:V3,decorators:[{type:Inject,args:[\"V3\"]}]}]", "{mode:?}" ); } @@ -343,36 +425,740 @@ export class Svc { } #[test] - fn unsure_imported_const_enum_keeps_its_type() { - // A namespace import (ngtsc: `undefined`), a file whose bare - // `namespace` block blurs its export names, a `.d.ts`-only module - // and a missing one: oxc can't be sure, so `i{n}.X` stays. + fn missing_or_nested_imported_const_enum_keeps_its_type() { + // A module that can't be read, and a class nested in a function + // (it may see another declaration of the name): `i{n}.X` stays. let dir = TempDir::new().unwrap(); write(&dir, "keys.ts", "export const enum KeyCodes { A = 1 }\n"); + let source = "import { Injectable, Inject } from '@angular/core'; +import * as k from './keys'; +import { Missing } from './missing'; +@Injectable() +export class Svc { + constructor(@Inject('M') m: Missing) {} +} +export function make() { + @Injectable() + class Nested { constructor(@Inject('K') a: k.KeyCodes) {} } + return Nested; +}"; + let path = write(&dir, "a.ts", source); + for mode in MODES { + let code = compile_at(&path, source, &options(mode, true)); + assert_contains(&code, "()=>[{type:i1.Missing,"); + assert_contains(&code, "()=>[{type:i2.KeyCodes,"); + assert_not_contains(&code, "type:undefined"); + } + } + + /// Write `files` to a new directory and compile its `a.ts`. + fn compile_files(files: &[(&str, &str)], options: &TransformOptions) -> String { + let dir = TempDir::new().unwrap(); + for (name, content) in files { + write(&dir, name, content); + } + let (_, source) = files.iter().find(|(name, _)| *name == "a.ts").unwrap(); + compile_at(&dir.path().join("a.ts").to_string_lossy(), source, options) + } + + const NS_KEYS: &str = "export const enum KeyCodes { A = 1, B = 2 } +export enum Plain { X = 1 } +export declare const enum Decl { A = 1 } +"; + + const NS_IMPORTER: [(&str, &str); 3] = [ + ("keys.ts", NS_KEYS), + ("barrel.ts", "export * from './keys';\nexport { KeyCodes as Renamed } from './keys';\n"), + ( + "a.ts", + "import { Injectable, Inject } from '@angular/core'; +import * as k from './keys'; +import * as b from './barrel'; +@Injectable() +export class Svc { + constructor(@Inject('K') a: k.KeyCodes, @Inject('P') p: k.Plain, @Inject('D') d: k.Decl, @Inject('BK') bk: b.KeyCodes, @Inject('BR') br: b.Renamed, @Inject('BP') bp: b.Plain, @Inject('NN') nn: k.KeyCodes | null, @Inject('M') m: k.KeyCodes.A) {} +}", + ), + ]; + + #[test] + fn namespace_import_const_enum_param_has_no_type() { + // ngtsc: an enum member (`k.KeyCodes.A`) keeps its reference, and + // the imports stay `i1` ./keys and `i2` ./barrel. + for mode in MODES { + let code = compile_files(&NS_IMPORTER, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:i1.Plain,decorators:[{type:Inject,args:[\"P\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"D\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"BK\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"BR\"]}]},\ + {type:i2.Plain,decorators:[{type:Inject,args:[\"BP\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"NN\"]}]},\ + {type:i1.KeyCodes.A,decorators:[{type:Inject,args:[\"M\"]}]}]", + "{mode:?}" + ); + assert_contains(&code, "import*asi1from'./keys'"); + assert_contains(&code, "import*asi2from'./barrel'"); + } + } + + #[test] + fn namespace_import_const_enum_keeps_its_type_without_the_option() { + for mode in MODES { + for options in [ + options(mode, false), + TransformOptions { + compilation_mode: mode, + cross_file_elision: true, + ..Default::default() + }, + ] { + let code = compile_files(&NS_IMPORTER, &options); + let params = ctor_params(&code); + assert_contains( + params, + "[{type:i1.KeyCodes,decorators:[{type:Inject,args:[\"K\"]}]},", + ); + assert_contains(params, "{type:i2.KeyCodes,"); + assert_contains(params, "{type:i2.Renamed,"); + } + } + } + + #[test] + fn namespace_import_of_only_const_enums_adds_no_import() { + // ngtsc: no `import * as i1 from "./keys"`. + let source = "import { Injectable, Inject } from '@angular/core'; +import * as k from './keys'; +@Injectable() +export class Svc { + constructor(@Inject('K') a: k.KeyCodes, @Inject('D') d: k.Decl) {} +}"; + for mode in MODES { + let code = + compile_files(&[("keys.ts", NS_KEYS), ("a.ts", source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"D\"]}]}]", + "{mode:?}" + ); + assert_not_contains(&code, "'./keys'"); + } + } + + #[test] + fn const_enum_in_an_imported_namespace_has_no_type() { + // `k.NS.Inner` and `NS.Inner` of `import { NS }`. ngtsc: `undefined` + // for the const enums, `i1.NS.InnerPlain` for the regular ones. + let keys = "export const enum KeyCodes { A = 1, B = 2 } +export namespace NS { export const enum Inner { A = 1 } export enum InnerPlain { A = 1 } } +"; + let source = "import { Injectable, Inject } from '@angular/core'; +import * as k from './keys'; +import { NS } from './keys'; +@Injectable() +export class Svc { + constructor(@Inject('I') i: k.NS.Inner, @Inject('IP') ip: k.NS.InnerPlain, @Inject('N') n: NS.Inner, @Inject('NP') np: NS.InnerPlain) {} +}"; + for mode in MODES { + let code = compile_files(&[("keys.ts", keys), ("a.ts", source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"I\"]}]},\ + {type:i1.NS.InnerPlain,decorators:[{type:Inject,args:[\"IP\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"N\"]}]},\ + {type:i1.NS.InnerPlain,decorators:[{type:Inject,args:[\"NP\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn every_re_export_form_of_a_const_enum_has_no_type() { + // `export { X } from`, renamed, `export *`, import then export, + // `export * as kk` through `export *`, and `export type { X } from`. + // ngtsc: `undefined` except the two regular enums. + let source = "import { Injectable, Inject } from '@angular/core'; +import { KeyCodes, Plain, Star, Renamed, LocalKC, kk, TypeKC } from './barrel'; +@Injectable() +export class Svc { + constructor(@Inject('KC') kc: KeyCodes, @Inject('P') p: Plain, @Inject('S') s: Star, @Inject('R') r: Renamed, @Inject('L') l: LocalKC, @Inject('KK') x: kk.KeyCodes, @Inject('KP') y: kk.Plain, @Inject('T') t: TypeKC) {} +}"; + let files = [ + ("keys.ts", "export const enum KeyCodes { A = 1 }\nexport enum Plain { X = 1 }\n"), + ("star.ts", "export const enum Star { A = 1 }\n"), + ("nsx.ts", "export * as kk from './keys';\n"), + ( + "barrel.ts", + "export { KeyCodes, Plain } from './keys'; +export { KeyCodes as Renamed } from './keys'; +export * from './star'; +export * from './nsx'; +import { KeyCodes as LocalKC } from './keys'; +export { LocalKC }; +export type { KeyCodes as TypeKC } from './keys'; +", + ), + ("a.ts", source), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"KC\"]}]},\ + {type:i1.Plain,decorators:[{type:Inject,args:[\"P\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"S\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"R\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"L\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"KK\"]}]},\ + {type:i1.kk.Plain,decorators:[{type:Inject,args:[\"KP\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"T\"]}]}]", + "{mode:?}" + ); + } + } + + const KK_FILES: [(&str, &str); 3] = [ + ("keys.ts", "export const enum KeyCodes { A = 1, B = 2 }\nexport enum Plain { X = 1 }\n"), + ("barrel.ts", "export * as kk from './keys';\n"), + ( + "a.ts", + "import { Injectable, Inject } from '@angular/core'; +import { kk } from './barrel'; +import * as b from './barrel'; +@Injectable() +export class Svc { + constructor(@Inject('K') a: kk.KeyCodes, @Inject('P') p: kk.Plain, @Inject('B') c: b.kk.KeyCodes, @Inject('A') d: b.kk.KeyCodes.A) {} +}", + ), + ]; + + #[test] + fn const_enum_through_export_star_as_has_no_type() { + // ngtsc: `undefined`, `i1.kk.Plain`, `undefined`, and the enum + // member keeps `i1.kk.KeyCodes.A`. + for mode in MODES { + let code = compile_files(&KK_FILES, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:i1.kk.Plain,decorators:[{type:Inject,args:[\"P\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"B\"]}]},\ + {type:i1.kk.KeyCodes.A,decorators:[{type:Inject,args:[\"A\"]}]}]", + "{mode:?}" + ); + } + } + + /// Whether `dependencies` lists `file`. + fn lists(dependencies: &[String], file: &str) -> bool { + let file = std::fs::canonicalize(file).unwrap(); + dependencies.iter().any(|dep| std::fs::canonicalize(dep).is_ok_and(|dep| dep == file)) + } + + #[test] + fn every_file_a_const_enum_lookup_reads_is_a_dependency() { + // `b.kk.KeyCodes` reads barrel.ts then keys.ts; `c.Gone` reads + // other.ts and finds nothing, which a later edit can change. + let dir = TempDir::new().unwrap(); + let keys = write(&dir, "keys.ts", "export const enum KeyCodes { A = 1 }\n"); + let barrel = write(&dir, "barrel.ts", "export * as kk from './keys';\n"); + let other = write(&dir, "other.ts", "export const x = 1;\n"); + let source = "import { Injectable, Inject } from '@angular/core'; +import * as b from './barrel'; +import * as c from './other'; +@Injectable() +export class Svc { + constructor(@Inject('B') a: b.kk.KeyCodes, @Inject('G') g: c.Gone) {} +}"; + let path = write(&dir, "a.ts", source); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + &path, + source, + Some(&options(CompilationMode::Full, true)), + None, + ); + for file in [&keys, &barrel, &other] { + assert!(lists(&result.dependencies, file), "{file}: {:?}", result.dependencies); + } + } + + const DTS: &str = "export declare const enum Typed { A = 1 } +export declare enum TypedPlain { A = 1 } +export declare namespace TNS { const enum E { A = 1 } } +declare const enum Hidden { A = 1 } +"; + + const DTS_IMPORTER: &str = "import { Injectable, Inject } from '@angular/core'; +import { Typed, TypedPlain, TNS, Hidden } from './typed'; +import * as t from './typed'; +@Injectable() +export class Svc { + constructor(@Inject('T') a: Typed, @Inject('TP') b: TypedPlain, @Inject('NS') c: TNS.E, @Inject('H') d: Hidden, @Inject('NT') e: t.Typed) {} +}"; + + #[test] + fn const_enum_from_a_declaration_file_has_no_type() { + // A `declare namespace` exports every member, and so does a `.d.ts` + // file without `export {}`: `Hidden` is exported. ngtsc: `undefined` + // except the regular enum. + let dir = TempDir::new().unwrap(); + let typed = write(&dir, "typed.d.ts", DTS); + let path = write(&dir, "a.ts", DTS_IMPORTER); + for mode in MODES { + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + &path, + DTS_IMPORTER, + Some(&options(mode, true)), + None, + ); + let code: String = result.code.chars().filter(|c| !c.is_whitespace()).collect(); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"T\"]}]},\ + {type:i1.TypedPlain,decorators:[{type:Inject,args:[\"TP\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"NS\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"H\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"NT\"]}]}]", + "{mode:?}" + ); + assert!(lists(&result.dependencies, &typed), "{:?}", result.dependencies); + } + } + + #[test] + fn const_enum_behind_a_js_specifier_has_no_type() { + // TypeScript reads `./x.js` as x.ts or x.d.ts, `.mjs` as `.d.mts`, + // `.cjs` as `.d.cts`, and prefers both.d.ts over both.js. The + // specifier written stays. ngtsc: `undefined` except the regular enum. + let source = "import { Injectable, Inject } from '@angular/core'; +import { Typed, TypedPlain } from './typed.js'; +import { KeyCodes } from './keys.js'; +import { Mjs } from './m.mjs'; +import { Cjs } from './c.cjs'; +import { Both } from './both'; +@Injectable() +export class Svc { + constructor(@Inject('T') a: Typed, @Inject('TP') b: TypedPlain, @Inject('K') c: KeyCodes, @Inject('M') d: Mjs, @Inject('C') e: Cjs, @Inject('B') f: Both) {} +}"; + let files = [ + ( + "typed.d.ts", + "export declare const enum Typed { A = 1 }\nexport declare enum TypedPlain { A = 1 }\n", + ), + ("keys.ts", "export const enum KeyCodes { A = 1 }\n"), + ("m.d.mts", "export declare const enum Mjs { A = 1 }\n"), + ("c.d.cts", "export declare const enum Cjs { A = 1 }\n"), + ("both.js", "export const Both = {};\n"), + ("both.d.ts", "export declare const enum Both { A = 1 }\n"), + ("a.ts", source), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"T\"]}]},\ + {type:i1.TypedPlain,decorators:[{type:Inject,args:[\"TP\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"M\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"C\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"B\"]}]}]", + "{mode:?}" + ); + assert_contains(&code, "import*asi1from'./typed.js'"); + } + } + + #[test] + fn const_enum_from_package_typings_has_no_type() { + // The `types` export condition (with `./x.js` chunks read as + // `.d.ts`, import then export, `export *`, a namespace), the + // `types` and `typings` fields, and `main` read as `.d.ts`. ngtsc: + // `undefined` except the two regular enums. + let source = "import { Injectable, Inject } from '@angular/core'; +import { Chunked, ChunkPlain, Renamed, Starred, Direct, DirectPlain, NS } from 'pkga'; +import * as pa from 'pkga'; +import { B1 } from 'pkgb'; +import { C1 } from 'pkgc'; +import { D1 } from 'pkgd'; +@Injectable() +export class Svc { + constructor(@Inject('1') a: Chunked, @Inject('2') b: ChunkPlain, @Inject('3') c: Renamed, @Inject('4') d: Starred, @Inject('5') e: Direct, @Inject('6') f: DirectPlain, @Inject('7') g: NS.Inner, @Inject('8') h: pa.Direct, @Inject('9') i: B1, @Inject('10') j: C1, @Inject('11') k: D1) {} +}"; + let files = [ + ( + "node_modules/pkga/package.json", + r#"{ "name": "pkga", "version": "1.0.0", "type": "module", "exports": { ".": { "types": "./types/index.d.ts", "default": "./fesm/index.mjs" } } }"#, + ), + ( + "node_modules/pkga/types/index.d.ts", + "import { Chunked } from './_chunk.js'; +export { Chunked }; +export { ChunkPlain, ChunkConst as Renamed } from './_chunk.js'; +export * from './_star.js'; +export declare const enum Direct { A = 1 } +export declare enum DirectPlain { A = 1 } +export declare namespace NS { const enum Inner { A = 1 } } +", + ), + ( + "node_modules/pkga/types/_chunk.d.ts", + "declare const enum Chunked { A = 1 } +declare enum ChunkPlain { A = 1 } +declare const enum ChunkConst { A = 1 } +export { Chunked, ChunkPlain, ChunkConst }; +", + ), + ("node_modules/pkga/types/_star.d.ts", "export declare const enum Starred { A = 1 }\n"), + ( + "node_modules/pkgb/package.json", + r#"{ "name": "pkgb", "version": "1.0.0", "main": "./dist/index.js", "types": "./dist/index.d.ts" }"#, + ), + ("node_modules/pkgb/dist/index.d.ts", "export declare const enum B1 { A = 1 }\n"), + ("node_modules/pkgb/dist/index.js", "export {}\n"), + ( + "node_modules/pkgc/package.json", + r#"{ "name": "pkgc", "version": "1.0.0", "main": "./lib/index.js", "typings": "./lib/index.d.ts" }"#, + ), + ("node_modules/pkgc/lib/index.d.ts", "export declare const enum C1 { A = 1 }\n"), + ("node_modules/pkgc/lib/index.js", "export {}\n"), + ( + "node_modules/pkgd/package.json", + r#"{ "name": "pkgd", "version": "1.0.0", "main": "./index.js" }"#, + ), + ("node_modules/pkgd/index.d.ts", "export declare const enum D1 { A = 1 }\n"), + ("node_modules/pkgd/index.js", "export {}\n"), + ("a.ts", source), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"1\"]}]},\ + {type:i1.ChunkPlain,decorators:[{type:Inject,args:[\"2\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"3\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"4\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"5\"]}]},\ + {type:i1.DirectPlain,decorators:[{type:Inject,args:[\"6\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"7\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"8\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"9\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"10\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"11\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn a_changed_package_file_is_read_again() { + // Package typings are kept for the whole process while unchanged. + let dir = TempDir::new().unwrap(); write( &dir, - "blurred.ts", - "export const enum Blurred { A = 1 }\nnamespace Inner { export class Blurred {} }\n", + "node_modules/pkge/package.json", + r#"{ "name": "pkge", "types": "index.d.ts" }"#, ); - write(&dir, "dts.d.ts", "export declare const enum Typed { A = 1 }\n"); + let typings = "node_modules/pkge/index.d.ts"; let source = "import { Injectable, Inject } from '@angular/core'; +import { E } from 'pkge'; +@Injectable() +export class Svc { + constructor(@Inject('E') e: E) {} +}"; + let file = write(&dir, "a.ts", source); + let options = options(CompilationMode::Full, true); + write(&dir, typings, "export declare const enum E { A = 1 }\n"); + assert_contains(&compile_at(&file, source, &options), "type:undefined"); + assert_contains(&compile_at(&file, source, &options), "type:undefined"); + write(&dir, typings, "export declare enum E { A = 1 }\n"); + assert_contains(&compile_at(&file, source, &options), "type:i1.E"); + } + + #[test] + fn exported_merges_through_a_namespace_import_have_no_type() { + // ngtsc: `undefined` four times, and no `./keys` import. + let keys = "export const enum T1 { A = 1 } +export namespace T1 { export type X = 1; } +export namespace T2 { export interface I {} } +export const enum T2 { A = 1 } +export declare const enum D1 { A = 1 } +export declare namespace D1 { type X = 1; } +"; + let source = "import { Injectable, Inject } from '@angular/core'; +import { T1, T2, D1 } from './keys'; import * as k from './keys'; +@Injectable() +export class Svc { + constructor(@Inject('T1') t1: T1, @Inject('T2') t2: T2, @Inject('D1') d1: D1, @Inject('KT1') kt1: k.T1) {} +}"; + for mode in MODES { + let code = compile_files(&[("keys.ts", keys), ("a.ts", source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"T1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"T2\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"D1\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"KT1\"]}]}]", + "{mode:?}" + ); + assert_not_contains(&code, "'./keys'"); + } + } + + #[test] + fn export_star_as_adds_no_names_to_a_plain_star_walk() { + // `export * as other` holds the const enum; the plain `export *` + // gives the regular enum that the import reads, whatever the file + // names or the order of the two lines. ngtsc: `i1.KeyCodes` and + // `i1.kk.KeyCodes`. + let importer = "import { Injectable, Inject } from '@angular/core'; +import { KeyCodes } from './barrel'; +@Injectable() +export class Svc { constructor(@Inject('K') a: KeyCodes) {} }"; + for (const_file, enum_file) in [("x", "mid"), ("zz", "aa")] { + let barrel = format!( + "export * as other from './{const_file}';\nexport * from './{enum_file}';\n" + ); + let files = [ + (&*format!("{const_file}.ts"), "export const enum KeyCodes { A = 1 }\n"), + (&*format!("{enum_file}.ts"), "export enum KeyCodes { A = 1 }\n"), + ("barrel.ts", &*barrel), + ("a.ts", importer), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:i1.KeyCodes,decorators:[{type:Inject,args:[\"K\"]}]}]", + "{mode:?}" + ); + assert_contains(&code, "from'./barrel'"); + } + } + let files = [ + ("keys.ts", "export enum KeyCodes { A = 1 }\n"), + ("keys2.ts", "export const enum KeyCodes { A = 1 }\n"), + ("mid.ts", "export * as kk from './keys';\n"), + ("x.ts", "export * as kk from './keys2';\n"), + ("barrel.ts", "export * as other from './x';\nexport * from './mid';\n"), + ( + "a.ts", + "import { Injectable, Inject } from '@angular/core'; +import * as b from './barrel'; +@Injectable() +export class Svc { constructor(@Inject('K') a: b.kk.KeyCodes) {} }", + ), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:i1.kk.KeyCodes,decorators:[{type:Inject,args:[\"K\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn default_exported_const_enum_has_no_type() { + // `export { E as default }` and `export default E`, read by a + // default import or `k.default`. ngtsc: `undefined` except + // `i1.Plain`. + let source = "import { Injectable, Inject } from '@angular/core'; +import KD from './keys'; +import * as k from './keys'; +import ED from './exdef'; +@Injectable() +export class Svc { + constructor(@Inject('KD') a: KD, @Inject('K') b: k.default, @Inject('P') c: k.Plain, @Inject('ED') d: ED) {} +}"; + let files = [ + ( + "keys.ts", + "const enum KeyCodes { A = 1 }\nexport { KeyCodes as default };\nexport enum Plain { X = 1 }\n", + ), + ("exdef.ts", "const enum E { A = 1 }\nexport default E;\n"), + ("a.ts", source), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"KD\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:i1.Plain,decorators:[{type:Inject,args:[\"P\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"ED\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn default_import_of_a_const_enum_is_removed() { + // Nothing reads the import once `type:` is `undefined`. ngtsc drops + // it, and `./typed` has no JS file to load. A const enum through a + // default namespace (`KD.Inner`) too. + let typed = "declare const enum E { A = 1 }\nexport default E;\n"; + let ns_keys = "enum E { A = 1 } +namespace E { export const enum Inner { B = 2 } } +export default E; +"; + let cases = [ + ("keys.ts", "const enum KeyCodes { A = 1 }\nexport { KeyCodes as default };\n", "KD"), + ("typed.d.ts", typed, "KD"), + ("keys.ts", ns_keys, "KD.Inner"), + ]; + for (file, content, param_type) in cases { + let module = file.trim_end_matches(".d.ts").trim_end_matches(".ts"); + let source = format!( + "import {{ Injectable, Inject }} from '@angular/core'; +import KD from './{module}'; +@Injectable() +export class Svc {{ + constructor(@Inject('KD') kd: {param_type}) {{}} +}}" + ); + for mode in MODES { + let code = + compile_files(&[(file, content), ("a.ts", &source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"KD\"]}]}]", + "{file} {mode:?}" + ); + assert_not_contains(&code, "importKD"); + } + } + } + + #[test] + fn default_import_of_a_const_enum_stays_while_the_output_names_it() { + // Today's output, kept: the factory names it (no `@Inject`; ngtsc + // writes `ɵɵinvalidFactory()` there), and so does a nested class's + // metadata, which keeps `type: KD`. + let keys = "const enum KeyCodes { A = 1 }\nexport { KeyCodes as default };\n"; + let factory = "import { Injectable } from '@angular/core'; +import KD from './keys'; +@Injectable() +export class Svc { constructor(kd: KD) {} }"; + let nested = "import { Injectable, Inject } from '@angular/core'; +import KD from './keys'; +@Injectable() +export class Svc { constructor(@Inject('KD') kd: KD) {} } +export function make() { + @Injectable() + class Nested { constructor(@Inject('KD') kd: KD) {} } + return Nested; +}"; + for source in [factory, nested] { + for mode in MODES { + let code = + compile_files(&[("keys.ts", keys), ("a.ts", source)], &options(mode, true)); + assert_contains(&code, "importKDfrom'./keys'"); + } + } + } + + #[test] + fn locally_type_exported_const_enum_has_no_type() { + // `export type { KeyCodes }` of a local const enum. ngtsc: + // `undefined`, `i1.Plain`, and the `./keys` import stays for it. + let source = "import { Injectable, Inject } from '@angular/core'; +import { KeyCodes, Plain } from './keys'; +@Injectable() +export class Svc { + constructor(@Inject('K') a: KeyCodes, @Inject('P') p: Plain) {} +}"; + let keys = "const enum KeyCodes { A = 1 }\nexport type { KeyCodes };\nexport enum Plain { X = 1 }\n"; + for mode in MODES { + let code = compile_files(&[("keys.ts", keys), ("a.ts", source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:i1.Plain,decorators:[{type:Inject,args:[\"P\"]}]}]", + "{mode:?}" + ); + assert_contains(&code, "import*asi1from'./keys'"); + } + } + + #[test] + fn const_enum_member_keeps_its_type() { + // An enum member is no `ConstEnum` symbol. ngtsc: `Local.A`, + // `i1.KeyCodes.A`, `i1.KeyCodes.A`. + let source = "import { Injectable, Inject } from '@angular/core'; +import * as k from './keys'; +import { KeyCodes } from './keys'; +const enum Local { A = 1 } +@Injectable() +export class Svc { + constructor(@Inject('A') a: Local.A, @Inject('B') b: KeyCodes.A, @Inject('C') c: k.KeyCodes.A) {} +}"; + let files = [("keys.ts", "export const enum KeyCodes { A = 1 }\n"), ("a.ts", source)]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:Local.A,decorators:[{type:Inject,args:[\"A\"]}]},\ + {type:i1.KeyCodes.A,decorators:[{type:Inject,args:[\"B\"]}]},\ + {type:i1.KeyCodes.A,decorators:[{type:Inject,args:[\"C\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn type_only_namespace_import_stays_undefined() { + // ngtsc: `undefined` for both, as before. + let source = "import { Injectable, Inject } from '@angular/core'; +import type * as k from './keys'; +@Injectable() +export class Svc { constructor(@Inject('K') a: k.KeyCodes, @Inject('P') p: k.Plain) {} }"; + let files = [ + ("keys.ts", "export const enum KeyCodes { A = 1 }\nexport enum Plain { X = 1 }\n"), + ("a.ts", source), + ]; + for mode in MODES { + let code = compile_files(&files, &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"K\"]}]},\ + {type:undefined,decorators:[{type:Inject,args:[\"P\"]}]}]", + "{mode:?}" + ); + } + } + + #[test] + fn a_namespace_member_does_not_hide_an_exported_const_enum() { + // A bare `namespace Inner` doesn't add to the file's exports. + // ngtsc: `undefined`. + let source = "import { Injectable, Inject } from '@angular/core'; import { Blurred } from './blurred'; -import { Typed } from './dts'; -import { Missing } from './missing'; @Injectable() export class Svc { - constructor(@Inject('K') a: k.KeyCodes, @Inject('B') b: Blurred, @Inject('T') t: Typed, @Inject('M') m: Missing) {} + constructor(@Inject('B') b: Blurred) {} }"; - let path = write(&dir, "a.ts", source); + let blurred = "export const enum Blurred { A = 1 } +namespace Inner { export class Blurred {} } +export const x = Inner; +"; for mode in MODES { - let code = compile_at(&path, source, &options(mode, true)); - let params = ctor_params(&code); - assert_contains(params, "{type:i1.KeyCodes,"); - assert_contains(params, ".Blurred,"); - assert_contains(params, ".Typed,"); - assert_contains(params, ".Missing,"); - assert_not_contains(params, "type:undefined"); + let code = + compile_files(&[("blurred.ts", blurred), ("a.ts", source)], &options(mode, true)); + assert_eq!( + ctor_params(&code), + "[{type:undefined,decorators:[{type:Inject,args:[\"B\"]}]}]", + "{mode:?}" + ); } } } diff --git a/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs index a54585efa..68f5748ac 100644 --- a/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs +++ b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs @@ -1,15 +1,16 @@ //! `ɵinj` imports of an NgModule (#623). In full and partial compilation, //! ngtsc's `NgModuleDecoratorHandler.resolve` leaves the directives and pipes -//! of `imports` out of the injector, and of `exports` also the components: -//! the runtime's `walkProviderTree` skips them. Oxc does the same when it -//! knows what an entry is: from this file alone, or, with cross-file -//! analysis (`TransformOptions::resolve_imported_values`), from the files and -//! packages it imports. Anything else stays as written. +//! of `imports` out of the injector, and so a standalone component that +//! brings in no providers (`mayExportProviders`). An entry it keeps only part +//! of is written as the classes it keeps, one by one, and `...LIST` as `LIST` +//! when it keeps all of it. `exports` adds each NgModule by itself. Oxc does +//! the same when it knows what an entry is: from this file alone, or, with +//! cross-file analysis (`TransformOptions::resolve_imported_values`), from the +//! files and packages it imports. Anything else stays as written, and so does +//! an entry whose kept classes are read through `forwardRef`. //! -//! Each expected output was checked against ngtsc 22.2.3. Where Oxc still -//! differs, the test says so: ngtsc also drops a standalone component from -//! `imports` when it brings in no providers (`mayExportProviders`), and -//! rewrites a list that is only partly dropped into the references it keeps. +//! Each expected output was checked against ngtsc 22.2.3. Where Oxc differs +//! on purpose, the test says so. use oxc_allocator::Allocator; use oxc_angular_compiler::{CompilationMode, TransformOptions, transform_angular_file}; @@ -81,6 +82,64 @@ fn assert_injectors(source: &str, expected: &[(&str, Option<&str>)], base: &Tran assert_injectors_at("app.ts", source, expected, base); } +/// Each `ɵinj` of `code` as `(class, imports)`, whitespace removed, in +/// order; `None` when it has no `imports`. Other fields are left out. +fn module_imports(code: &str) -> Vec<(String, Option)> { + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let mut modules = Vec::new(); + let mut rest = compact.as_str(); + let mut before = 0; + while let Some(at) = rest.find("ɵinj=") { + let head = &compact[..before + at]; + let name = &head[head.rfind("class").unwrap() + 5..]; + let name = &name[..name.find(['{', '(']).unwrap()]; + let init = &rest[at..rest[at..].find(';').unwrap() + at]; + let imports = init.find("imports:[").map(|start| { + let list = &init[start + 9..]; + let mut depth = 1; + let end = list + .char_indices() + .find(|(_, c)| { + match c { + '[' => depth += 1, + ']' => depth -= 1, + _ => {} + } + depth == 0 + }) + .unwrap() + .0; + list[..end].to_string() + }); + modules.push((name.to_string(), imports)); + before += at + 5; + rest = &compact[before..]; + } + modules +} + +/// Compile `source` as `path` in full and partial compilation, and check +/// the `imports` of each `ɵinj`, by module, in order. +fn assert_module_imports( + path: &str, + source: &str, + expected: &[(&str, Option<&str>)], + base: &TransformOptions, +) -> [String; 2] { + [CompilationMode::Full, CompilationMode::Partial].map(|mode| { + let options = TransformOptions { compilation_mode: mode, ..base.clone() }; + let allocator = Allocator::default(); + let result = transform_angular_file(&allocator, path, source, Some(&options), None); + assert!(!result.has_errors(), "Should not have errors: {:?}", result.diagnostics); + let want: Vec<(String, Option)> = expected + .iter() + .map(|(name, imports)| (name.to_string(), imports.map(Into::into))) + .collect(); + assert_eq!(module_imports(&result.code), want, "{mode:?}"); + result.code + }) +} + #[test] fn exports_that_are_also_declared_are_left_out() { // ngtsc: `ɵɵdefineInjector({})`, and no `imports` in partial. @@ -98,9 +157,8 @@ import { Other } from './other'; #[test] fn directives_and_pipes_of_this_file_are_left_out() { - // ngtsc gives `{}` for both: it also drops `LocalCmp`, a standalone - // component that brings in no providers. Oxc keeps components in - // `imports`. + // ngtsc gives `{}` for both: `LocalCmp` is a standalone component that + // brings in no providers. assert_injectors( r" import { Directive, Pipe, NgModule, Component } from '@angular/core'; @@ -110,7 +168,7 @@ import { Directive, Pipe, NgModule, Component } from '@angular/core'; @NgModule({ imports: [StandaloneDir, StandalonePipe], exports: [StandaloneDir, StandalonePipe] }) export class ReexportModule {} @NgModule({ imports: [StandaloneDir, StandalonePipe, LocalCmp], exports: [StandaloneDir] }) export class LocalModule {} ", - &[("ReexportModule", None), ("LocalModule", Some("LocalCmp"))], + &[("ReexportModule", None), ("LocalModule", None)], &TransformOptions::default(), ); } @@ -158,7 +216,7 @@ export class AliasModule {} fn an_exported_component_stays_unless_imported_or_declared() { // ngtsc drops `Cmp` from `exports` and, as it has no providers, from // `imports` too. A component exported without being imported or declared - // is an error in ngtsc; Oxc keeps it, as its providers may be needed. + // is an error in ngtsc; Oxc keeps it, as before. assert_injectors( r" import { Component, NgModule } from '@angular/core'; @@ -166,7 +224,24 @@ import { Component, NgModule } from '@angular/core'; @Component({ selector: 'o', template: '' }) export class Orphan {} @NgModule({ imports: [Cmp], exports: [Cmp, Orphan] }) export class M {} ", - &[("M", Some("Cmp,Orphan"))], + &[("M", Some("Orphan"))], + &TransformOptions::default(), + ); +} + +#[test] +fn a_component_that_is_not_standalone_stays() { + // ngtsc fails here (-992011: not standalone, can't be imported). Oxc + // keeps such a component, alone or in a list, as before. `Dir` is left + // out. + assert_injectors( + r" +import { Component, Directive, NgModule } from '@angular/core'; +@Component({ selector: 'l', template: '', standalone: false }) export class LocalNonStd {} +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({ imports: [LocalNonStd, [Dir, LocalNonStd]] }) export class M {} +", + &[("M", Some("LocalNonStd,LocalNonStd"))], &TransformOptions::default(), ); } @@ -218,6 +293,203 @@ export function make() { assert_eq!(got, vec!["/*@__PURE__*/i0.ɵɵdefineInjector({imports:[D]})".to_string()]); } +/// The ngtsc case of standalone components with and without providers, in +/// one file. +const PROVIDER_CASES: &str = r" +import { Component, NgModule, Injectable, forwardRef } from '@angular/core'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) export class ProvModule {} +@NgModule({ providers: [] }) export class EmptyProvModule {} +@NgModule({}) export class PlainModule {} +@NgModule({ imports: [ProvModule] }) export class WrapProvModule {} +@NgModule({ imports: [PlainModule] }) export class WrapPlainModule {} +@Component({ selector: 'c-empty', template: '' }) export class CEmpty {} +@Component({ selector: 'c-own', template: '', providers: [Svc], viewProviders: [Svc] }) export class COwnProviders {} +@Component({ selector: 'c-prov', template: '', imports: [ProvModule] }) export class CProv {} +@Component({ selector: 'c-emptyprov', template: '', imports: [EmptyProvModule] }) export class CEmptyProv {} +@Component({ selector: 'c-plain', template: '', imports: [PlainModule] }) export class CPlain {} +@Component({ selector: 'c-wrapprov', template: '', imports: [WrapProvModule] }) export class CWrapProv {} +@Component({ selector: 'c-wrapplain', template: '', imports: [WrapPlainModule] }) export class CWrapPlain {} +@Component({ selector: 'c-nested-prov', template: '', imports: [CProv] }) export class CNestedProv {} +@Component({ selector: 'c-nested-empty', template: '', imports: [CEmpty] }) export class CNestedEmpty {} +@Component({ selector: 'c-cyc-a', template: '', imports: [forwardRef(() => CCycB)] }) export class CCycA {} +@Component({ selector: 'c-cyc-b', template: '', imports: [CCycA] }) export class CCycB {} +@Component({ selector: 'c-cyc-pa', template: '', imports: [forwardRef(() => CCycPB)] }) export class CCycPA {} +@Component({ selector: 'c-cyc-pb', template: '', imports: [CCycPA, ProvModule] }) export class CCycPB {} +@NgModule({ imports: [CEmpty, COwnProviders, CProv, CEmptyProv, CPlain, CWrapProv, CWrapPlain, CNestedProv, CNestedEmpty, CCycA, CCycB, CCycPA, CCycPB] }) +export class AppModule {} +"; + +/// The modules of [`PROVIDER_CASES`] and their `ɵinj` imports, from ngtsc. +const PROVIDER_CASES_IMPORTS: &[(&str, Option<&str>)] = &[ + ("ProvModule", None), + ("EmptyProvModule", None), + ("PlainModule", None), + ("WrapProvModule", Some("ProvModule")), + ("WrapPlainModule", Some("PlainModule")), + ("AppModule", Some("CProv,CEmptyProv,CWrapProv,CNestedProv,CCycPA,CCycPB")), +]; + +#[test] +fn standalone_components_without_providers_are_left_out() { + // A component stays when its imports reach an NgModule with a + // `providers` key (even `[]`), through components and NgModules. Its + // own providers don't count. + assert_module_imports( + "app.ts", + PROVIDER_CASES, + PROVIDER_CASES_IMPORTS, + &TransformOptions::default(), + ); +} + +#[test] +fn any_providers_key_counts() { + // ngtsc: `[CUndef, CConst]`; `CPipe` imports a pipe only. + assert_module_imports( + "app.ts", + r" +import { Component, Pipe, NgModule } from '@angular/core'; +@Pipe({ name: 'p' }) export class P { transform(v: any) { return v; } } +@Component({ selector: 'c-pipe', template: '', imports: [P] }) export class CPipe {} +@NgModule({ providers: undefined }) export class UndefProv {} +@Component({ selector: 'c-undef', template: '', imports: [UndefProv] }) export class CUndef {} +const PROVS: any[] = []; +@NgModule({ providers: PROVS }) export class ConstProv {} +@Component({ selector: 'c-const', template: '', imports: [ConstProv] }) export class CConst {} +@NgModule({ imports: [CPipe, CUndef, CConst] }) export class M {} +", + &[("UndefProv", None), ("ConstProv", None), ("M", Some("CUndef,CConst"))], + &TransformOptions::default(), + ); +} + +/// Lists, `forwardRef` and `ModuleWithProviders` of one file, and a +/// component behind a `ModuleWithProviders`. +const SAME_FILE_LISTS: &str = r" +import { Component, Directive, Injectable, NgModule, forwardRef } from '@angular/core'; +import { ImportedModule } from './other'; +@Injectable() export class Svc {} +@Directive({ selector: '[ld]' }) export class LocalDir {} +@NgModule({}) export class LocalMod {} +@NgModule({ providers: [Svc] }) export class ProvModule {} +@NgModule({}) export class BareForRoot { static forRoot() { return { ngModule: BareForRoot, providers: [Svc] }; } } +@NgModule({ imports: [BareForRoot.forRoot()] }) export class ImportsMwpModule {} +@Component({ selector: 'c-mwp', template: '' , imports: [ImportsMwpModule] }) export class CMwp {} +@Component({ selector: 'c-imp', template: '', imports: [ImportedModule] }) export class CImported {} +const SAME = [LocalDir, LocalMod]; +const SAME_IMP = [LocalDir, ImportedModule]; +const EMPTY: any[] = []; +const SAME_FWD = [forwardRef(() => LocalDir), forwardRef(() => LateMod)]; +const SAME_MWP = [LocalDir, BareForRoot.forRoot()]; +@NgModule({ imports: [...SAME] }) export class M1 {} +@NgModule({ imports: [SAME_IMP] }) export class M2 {} +@NgModule({ imports: [[LocalDir, LocalMod], [LocalMod]] }) export class M3 {} +@NgModule({ imports: [...EMPTY, LocalMod] }) export class M4 {} +@NgModule({ imports: [...SAME_FWD] }) export class M5 {} +@NgModule({ imports: [forwardRef(() => LATER)] }) export class M6 {} +@NgModule({ imports: [...SAME_MWP] }) export class M7 {} +@NgModule({ imports: SAME }) export class M8 {} +@NgModule({ imports: [CMwp, CImported] }) export class M9 {} +@NgModule({}) export class LateMod {} +export const LATER = [LocalDir, LateMod]; +"; + +/// The modules of [`SAME_FILE_LISTS`] and their `ɵinj` imports; `M2` and +/// `M9` as this file alone shows them. +const SAME_FILE_LISTS_IMPORTS: &[(&str, Option<&str>)] = &[ + ("LocalMod", None), + ("ProvModule", None), + ("BareForRoot", None), + ("ImportsMwpModule", Some("BareForRoot.forRoot()")), + ("M1", Some("LocalMod")), + ("M2", Some("SAME_IMP")), + ("M3", Some("LocalMod,[LocalMod]")), + ("M4", Some("EMPTY,LocalMod")), + // ngtsc: `[LateMod]`, which reads `LateMod` before it exists. + ("M5", Some("...SAME_FWD")), + ("M6", Some("forwardRef(()=>LATER)")), + ("M7", Some("SAME_MWP")), + ("M8", Some("LocalMod")), + ("M9", Some("CImported")), + ("LateMod", None), +]; + +#[test] +fn lists_of_this_file_are_written_like_ngtsc() { + // A list kept in part is written as the classes it keeps, a spread kept + // whole as its argument, and a list holding a `ModuleWithProviders` as + // is. ngtsc also drops `CMwp`: it only reaches the providers of + // `BareForRoot.forRoot()` through an NgModule without a `providers` key. + // Without cross-file analysis, `ImportedModule` is not known, so `M2` + // and `CImported` stay. + assert_module_imports( + "app.ts", + SAME_FILE_LISTS, + SAME_FILE_LISTS_IMPORTS, + &TransformOptions::default(), + ); +} + +#[test] +fn exports_of_this_file_are_written_one_by_one() { + // ngtsc writes each exported NgModule by itself. For `E4` it writes + // `[LateMod]`, which reads `LateMod` before it exists: the `forwardRef` + // stays. + assert_module_imports( + "app.ts", + r" +import { Directive, NgModule, forwardRef } from '@angular/core'; +import { OtherModule as OM } from './dep'; +@Directive({ selector: '[ld]', standalone: false }) export class LocalDir {} +@Directive({ selector: '[ld2]' }) export class StandaloneDir {} +@NgModule({}) export class LocalMod {} +const EMPTY: any[] = []; +const MODS = [LocalDir, LocalMod]; +const MODS2 = [StandaloneDir, LocalMod]; +@NgModule({ exports: [...EMPTY] }) export class E1 {} +@NgModule({ declarations: [LocalDir], exports: [...MODS] }) export class E2 {} +@NgModule({ imports: [StandaloneDir], exports: [...MODS2, OM] }) export class E3 {} +@NgModule({ exports: [forwardRef(() => LateMod)] }) export class E4 {} +@NgModule({}) export class LateMod {} +", + &[ + ("LocalMod", None), + ("E1", None), + ("E2", Some("LocalMod")), + ("E3", Some("LocalMod,OM")), + ("E4", Some("forwardRef(()=>LateMod)")), + ("LateMod", None), + ], + &TransformOptions::default(), + ); +} + +#[test] +fn a_component_whose_providers_cannot_be_read_keeps_its_entry() { + // ngtsc reads `mods()` and `FLAG`, finds no providers and leaves both + // lists out (`ɵinj` without `imports`). Oxc can't read either, so the + // lists stay as written rather than turning into `[UCmp]` / `[FCmp]`. + assert_module_imports( + "app.ts", + r" +import { Component, Directive, NgModule } from '@angular/core'; +const FLAG = true; +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({}) export class PlainModule {} +function mods() { return [PlainModule]; } +@Component({ selector: 'u', template: '', imports: [mods()] }) export class UCmp {} +@Component({ selector: 'f', template: '', standalone: FLAG }) export class FCmp {} +const L = [Dir, UCmp]; +const LF = [Dir, FCmp]; +@NgModule({ imports: [...L] }) export class M1 {} +@NgModule({ imports: [...LF] }) export class M2 {} +", + &[("PlainModule", None), ("M1", Some("...L")), ("M2", Some("...LF"))], + &TransformOptions::default(), + ); +} + #[cfg(feature = "cross_file_elision")] mod cross_file { //! With cross-file analysis, imported classes, `const` lists and package @@ -227,7 +499,10 @@ mod cross_file { use oxc_angular_compiler::{TransformOptions, transform_angular_file}; use tempfile::TempDir; - use super::{assert_injectors_at, injectors}; + use super::{ + PROVIDER_CASES, PROVIDER_CASES_IMPORTS, SAME_FILE_LISTS, SAME_FILE_LISTS_IMPORTS, + assert_injectors_at, assert_module_imports, injectors, + }; fn write(dir: &TempDir, name: &str, content: &str) -> std::path::PathBuf { let path = dir.path().join(name); @@ -251,6 +526,47 @@ mod cross_file { assert_injectors_at(path.to_str().unwrap(), source, expected, options); } + /// Compile `src/app.ts` in `dir` with cross-file analysis and check the + /// `imports` of each `ɵinj`. Returns the full and the partial code. + fn check_modules( + dir: &TempDir, + source: &str, + expected: &[(&str, Option<&str>)], + ) -> [String; 2] { + let path = write(dir, "src/app.ts", source); + assert_module_imports(path.to_str().unwrap(), source, expected, &cross_file()) + } + + /// `node_modules/fakelib`: a package's declaration file. + fn fakelib(dir: &TempDir) { + write( + dir, + "node_modules/fakelib/package.json", + r#"{ "name": "fakelib", "types": "index.d.ts" }"#, + ); + write( + dir, + "node_modules/fakelib/index.d.ts", + r#" +import * as i0 from '@angular/core'; +export declare class LibCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class LibDir { + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class LibNonStdCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; +} +export declare class LibModule { + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +export declare const LIB_LIST: readonly [typeof LibDir, typeof LibModule]; +"#, + ); + } + /// `src/dep.ts` of the ngtsc case: standalone classes and two modules. fn dep(dir: &TempDir) { write( @@ -311,9 +627,7 @@ export { NgIf, NgClass, AsyncPipe, CommonModule }; #[test] fn imported_directives_and_pipes_are_left_out() { - // ngtsc: `[DirModule, ReexportModule, LocalModule, DirModule, - // ReexportModule, LocalModule]`: it also drops `StandaloneCmp`, - // which brings in no providers. + // `StandaloneCmp` brings in no providers. let dir = TempDir::new().unwrap(); dep(&dir); check( @@ -335,7 +649,7 @@ export class AppModule {} ( "AppModule", Some( - "StandaloneCmp,DirModule,ReexportModule,LocalModule,DirModule,ReexportModule,LocalModule", + "DirModule,ReexportModule,LocalModule,DirModule,ReexportModule,LocalModule", ), ), ], @@ -345,10 +659,8 @@ export class AppModule {} #[test] fn namespaces_lists_and_reexported_components() { - // ngtsc, module by module: `[dep.PlainModule]`, `{}`, - // `[i1.PlainModule, i1.PlainModule]` (Oxc keeps a list that is only - // partly dropped whole), and `[CmpModule, CmpModule]` (it also drops - // `StandaloneCmp`, which brings in no providers). + // A list kept in part is written as the classes it keeps, through a + // new namespace import, and `StandaloneCmp` brings in no providers. let dir = TempDir::new().unwrap(); dep(&dir); check( @@ -369,8 +681,8 @@ export class CmpReexportModule {} &[ ("NsModule", Some("dep.PlainModule")), ("WholeListModule", None), - ("MixedModule", Some("...MIXED,MIXED")), - ("CmpReexportModule", Some("StandaloneCmp,CmpModule,CmpModule")), + ("MixedModule", Some("i1.PlainModule,i1.PlainModule")), + ("CmpReexportModule", Some("CmpModule,CmpModule")), ], &cross_file(), ); @@ -453,6 +765,90 @@ export class M {} ); } + #[test] + fn exports_stay_when_the_scope_cannot_be_read() { + // Oxc can't read `decls()`, so it doesn't know whether `CmpA` is in + // the module's scope: `...EXP` stays as written. ngtsc reads it and + // writes `[SharedModule, i1.SharedModule]`. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Component, Directive, NgModule } from '@angular/core'; +@Component({ selector: 'a', template: '', standalone: false }) export class CmpA {} +@Component({ selector: 'b', template: '', standalone: false }) export class CmpB {} +@Directive({ selector: '[b]', standalone: false }) export class DirB {} +@NgModule({}) export class SharedModule {} +export const DECLS = [CmpA, DirB]; +export function decls() { return [CmpB]; } +export const EXP = [CmpA, DirB, SharedModule]; +", + ); + check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { DECLS, EXP, SharedModule, decls } from './dep'; +@NgModule({ declarations: [...DECLS, decls()], imports: [SharedModule], exports: [...EXP] }) +export class M {} +", + &[("M", Some("SharedModule,...EXP"))], + ); + } + + #[test] + fn a_component_whose_providers_cannot_be_read_keeps_its_list() { + // Oxc can't read `mods()`: `UCmp` may bring in providers, so `...L` + // stays as written. ngtsc reads it and leaves the list out. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Component, Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({}) export class PlainModule {} +export function mods() { return [PlainModule]; } +@Component({ selector: 'u', template: '', imports: [mods()] }) export class UCmp {} +export const L = [Dir, UCmp]; +", + ); + check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { L } from './dep'; +@NgModule({ imports: [...L] }) export class M {} +", + &[("M", Some("...L"))], + ); + } + + #[test] + fn a_package_class_reached_through_a_relative_file_keeps_its_entry() { + // `./lib` re-exports the package, so the class has no package + // specifier here. ngtsc writes `[i1.LibModule]` with a relative path + // into node_modules, which may not resolve and, under a dev server, + // loads a second copy of the package: the entry stays. Imported from + // the package itself, the class is named through it. + let dir = TempDir::new().unwrap(); + fakelib(&dir); + write(&dir, "src/lib.ts", "export * from 'fakelib';\n"); + let [full, _] = check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { LIB_LIST } from './lib'; +import { LIB_LIST as PKG_LIST } from 'fakelib'; +@NgModule({ imports: [...LIB_LIST] }) export class M1 {} +@NgModule({ imports: [...PKG_LIST] }) export class M2 {} +", + &[("M1", Some("...LIB_LIST")), ("M2", Some("i1.LibModule"))], + ); + assert!(!full.contains("node_modules"), "{full}"); + } + #[test] fn a_list_through_a_local_forward_ref_stays() { // This `forwardRef` returns `PlainModule`, not what its callback gives. @@ -523,6 +919,734 @@ export class M {} ); } + #[test] + fn standalone_components_of_this_file_without_providers() { + let dir = TempDir::new().unwrap(); + check_modules(&dir, PROVIDER_CASES, PROVIDER_CASES_IMPORTS); + } + + #[test] + fn lists_of_this_file_with_imported_classes() { + // `ImportedModule` is now known: an NgModule without providers. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/other.ts", + "import { NgModule } from '@angular/core';\n@NgModule({}) export class ImportedModule {}\n", + ); + let expected: Vec<(&str, Option<&str>)> = SAME_FILE_LISTS_IMPORTS + .iter() + .map(|&(name, imports)| match name { + "M2" => (name, Some("ImportedModule")), + "M9" => (name, None), + _ => (name, imports), + }) + .collect(); + check_modules(&dir, SAME_FILE_LISTS, &expected); + } + + #[test] + fn imported_standalone_components() { + // A component reaching providers through a `forwardRef`, a list, or + // a package's NgModule stays. One with its own providers only, in a + // cycle without providers, or with `imports: []` is left out. + let dir = TempDir::new().unwrap(); + fakelib(&dir); + write( + &dir, + "src/dep.ts", + r" +import { Component, NgModule, Injectable, forwardRef } from '@angular/core'; +import { LibModule } from 'fakelib'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) export class ProvModule {} +@NgModule({ providers: [] }) export class EmptyProvModule {} +@NgModule({}) export class PlainModule {} +const CLIST = [PlainModule, ProvModule]; +@Component({ selector: 'a', template: '', imports: [forwardRef(() => ProvModule)] }) export class AFwdProv {} +@Component({ selector: 'b', template: '', imports: [...CLIST] }) export class BList {} +@Component({ selector: 'd', template: '', imports: [LibModule] }) export class DLibMod {} +@Component({ selector: 'e', template: '', imports: [EmptyProvModule] }) export class EEmptyProv {} +@Component({ selector: 'f', template: '', imports: [PlainModule], providers: [Svc] }) export class FPlain {} +@Component({ selector: 'g', template: '', imports: [forwardRef(() => HCyc)] }) export class GCyc {} +@Component({ selector: 'h', template: '', imports: [GCyc] }) export class HCyc {} +@Component({ selector: 'i', template: '', standalone: true, imports: [] }) export class IEmptyImports {} +", + ); + let source = r" +import { NgModule } from '@angular/core'; +import { AFwdProv, BList, DLibMod, EEmptyProv, FPlain, GCyc, HCyc, IEmptyImports } from './dep'; +@NgModule({ imports: [AFwdProv, BList, DLibMod, EEmptyProv, FPlain, GCyc, HCyc, IEmptyImports] }) +export class AppModule {} +"; + check_modules(&dir, source, &[("AppModule", Some("AFwdProv,BList,DLibMod,EEmptyProv"))]); + let path = dir.path().join("src/app.ts"); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + path.to_str().unwrap(), + source, + Some(&cross_file()), + None, + ); + assert!( + result.dependencies.iter().any(|dep| dep.ends_with("dep.ts")), + "{:?}", + result.dependencies + ); + } + + #[test] + fn components_that_are_not_standalone_stay() { + // ngtsc fails here (-992011: not standalone, can't be imported). Oxc + // keeps such a component from another file, a package or a list, as + // before. The directives are left out. + let dir = TempDir::new().unwrap(); + dep(&dir); + fakelib(&dir); + write( + &dir, + "src/list.ts", + "import { NonStdCmp } from './dep';\nexport const NL = [NonStdCmp];\n", + ); + check_modules( + &dir, + r" +import { Component, Directive, NgModule } from '@angular/core'; +import { NonStdCmp } from './dep'; +import { NL } from './list'; +import { LibNonStdCmp, LibDir } from 'fakelib'; +@Component({ selector: 'l', template: '', standalone: false }) export class LocalNonStd {} +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({ + imports: [NonStdCmp, LibNonStdCmp, LocalNonStd, [Dir, LocalNonStd], [LibDir, LibNonStdCmp], ...NL], +}) +export class M {} +", + &[("M", Some("NonStdCmp,LibNonStdCmp,LocalNonStd,LocalNonStd,LibNonStdCmp,NL"))], + ); + } + + #[test] + fn a_component_with_imports_that_cannot_be_read_is_still_known() { + // Oxc can't read `mods()`, so `UCmp` stays in the `ɵinj` (ngtsc + // reads it and leaves `UCmp` out). `UCmp` is still a known + // component: `ACmp` gets its scope, as before. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Component, Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({}) export class PlainModule {} +export function mods() { return [PlainModule]; } +@Component({ selector: 'u', template: '', imports: [mods()] }) export class UCmp {} +", + ); + let [_, partial] = check_modules( + &dir, + r" +import { Component, NgModule } from '@angular/core'; +import { UCmp, Dir } from './dep'; +@Component({ selector: 'a', template: '', standalone: false }) export class ACmp {} +@NgModule({ declarations: [ACmp], imports: [UCmp, Dir] }) export class M {} +", + &[("M", Some("UCmp"))], + ); + assert!( + partial + .contains("i0.ɵɵsetComponentScope(ACmp, () => [i1.UCmp, i1.Dir, ACmp], () => []);"), + "{partial}" + ); + } + + #[test] + fn module_with_providers_behind_a_component_is_not_read() { + // Like ngtsc: an NgModule without a `providers` key brings in none, + // even when it imports `BareForRoot.forRoot()`. Both components are + // left out, so `Svc2` is not provided and `SideModule` is not + // created with `AppModule`'s injector. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Component, NgModule, Injectable } from '@angular/core'; +@Injectable() export class Svc2 {} +@NgModule({}) export class BareForRoot { static forRoot() { return { ngModule: BareForRoot, providers: [Svc2] }; } } +@NgModule({ imports: [BareForRoot.forRoot()] }) export class ImportsMwpModule {} +@Component({ selector: 'c', template: '', imports: [ImportsMwpModule] }) export class CMwp {} +@NgModule({}) export class SideModule { constructor() { (globalThis as any).ran = ((globalThis as any).ran || 0) + 1; } } +@Component({ selector: 'c-side', template: '', imports: [SideModule] }) export class CSide {} +", + ); + check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { CMwp, CSide } from './dep'; +@NgModule({ imports: [CMwp, CSide] }) export class AppModule {} +", + &[("AppModule", None)], + ); + } + + /// `src/dep.ts` and `src/local.d.ts` of the ngtsc case of lists from + /// another file, with `node_modules/fakelib`. + fn dep_lists(dir: &TempDir) { + fakelib(dir); + write( + dir, + "src/local.d.ts", + r#" +import * as i0 from '@angular/core'; +export declare class DtsCmp { + static ɵcmp: i0.ɵɵComponentDeclaration; +} +"#, + ); + write( + dir, + "src/dep.ts", + r" +import { Component, Directive, NgModule, Injectable } from '@angular/core'; +import { LibCmp, LibDir } from 'fakelib'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) export class ProvModule {} +@NgModule({}) export class PlainModule {} +@Directive({ selector: '[d]' }) export class Dir {} +@Component({ selector: 'x-empty', template: '' }) export class XEmpty {} +@Component({ selector: 'x-prov', template: '', imports: [ProvModule] }) export class XProv {} +@Component({ selector: 'x-libcmp', template: '', imports: [LibCmp] }) export class XLibCmp {} +@Component({ selector: 'x-libdir', template: '', imports: [LibDir] }) export class XLibDir {} +export const LIST = [Dir, ProvModule]; +export const LIST_CMP = [XEmpty, XProv, PlainModule]; +export const ALL_KEPT = [ProvModule, PlainModule]; +export const ALL_DROP = [Dir, XEmpty]; +", + ); + } + + /// The app of the ngtsc case of lists from another file. + const DEP_LISTS_APP: &str = r" +import { NgModule } from '@angular/core'; +import { LibCmp, LibDir, LibModule, LIB_LIST } from 'fakelib'; +import { DtsCmp } from './local'; +import { XEmpty, XProv, XLibCmp, XLibDir, LIST, LIST_CMP, ALL_KEPT, ALL_DROP, Dir, ProvModule } from './dep'; +import * as dep from './dep'; +@NgModule({ imports: [XEmpty, XProv, XLibCmp, XLibDir, LibCmp, LibDir, LibModule, DtsCmp, dep.XEmpty, dep.XProv] }) +export class AModule {} +@NgModule({ imports: [...LIST] }) export class SpreadModule {} +@NgModule({ imports: [LIST] }) export class NestedModule {} +@NgModule({ imports: LIST }) export class BareModule {} +@NgModule({ imports: [...LIST_CMP, Dir] }) export class SpreadCmpModule {} +@NgModule({ imports: [...ALL_KEPT, ALL_KEPT] }) export class AllKeptModule {} +@NgModule({ imports: [...ALL_DROP, ProvModule] }) export class AllDropModule {} +@NgModule({ imports: [...LIB_LIST] }) export class LibListModule {} +@NgModule({ imports: [dep.LIST] }) export class NsListModule {} +@NgModule({ imports: [...LIST, ProvModule] }) export class DupModule {} +"; + + #[test] + fn lists_from_another_file() { + // A component of a declaration file is taken to bring in providers; + // a list is written as the classes it keeps, named through a new + // namespace import of the file or package they come from. + let dir = TempDir::new().unwrap(); + dep_lists(&dir); + let code = check_modules( + &dir, + DEP_LISTS_APP, + &[ + ("AModule", Some("XProv,XLibCmp,LibCmp,LibModule,DtsCmp,dep.XProv")), + ("SpreadModule", Some("i1.ProvModule")), + ("NestedModule", Some("i1.ProvModule")), + ("BareModule", Some("i1.ProvModule")), + ("SpreadCmpModule", Some("i1.XProv,i1.PlainModule")), + ("AllKeptModule", Some("ALL_KEPT,ALL_KEPT")), + ("AllDropModule", Some("ProvModule")), + ("LibListModule", Some("i2.LibModule")), + ("NsListModule", Some("i1.ProvModule")), + ("DupModule", Some("i1.ProvModule,ProvModule")), + ], + ); + for code in &code { + assert!(code.contains("import * as i1 from './dep';"), "{code}"); + assert!(code.contains("import * as i2 from 'fakelib';"), "{code}"); + } + // The JIT scope keeps the lists as written. + let full: String = code[0].chars().filter(|c| !c.is_whitespace()).collect(); + assert!(full.contains("ɵɵsetNgModuleScope(SpreadModule,{imports:[...LIST]})"), "{full}"); + } + + #[test] + fn lists_stay_without_cross_file() { + let dir = TempDir::new().unwrap(); + dep_lists(&dir); + let path = write(&dir, "src/app.ts", DEP_LISTS_APP); + assert_module_imports( + path.to_str().unwrap(), + DEP_LISTS_APP, + &[ + ( + "AModule", + Some( + "XEmpty,XProv,XLibCmp,XLibDir,LibCmp,LibDir,LibModule,DtsCmp,dep.XEmpty,dep.XProv", + ), + ), + ("SpreadModule", Some("...LIST")), + ("NestedModule", Some("LIST")), + ("BareModule", Some("LIST")), + ("SpreadCmpModule", Some("...LIST_CMP,Dir")), + ("AllKeptModule", Some("...ALL_KEPT,ALL_KEPT")), + ("AllDropModule", Some("...ALL_DROP,ProvModule")), + ("LibListModule", Some("...LIB_LIST")), + ("NsListModule", Some("dep.LIST")), + ("DupModule", Some("...LIST,ProvModule")), + ], + &TransformOptions::default(), + ); + } + + #[test] + fn classes_are_named_from_the_file_that_declares_them() { + // ngtsc imports `./third`, `fakelib`, `./dep` and `./local` in that + // order. A list holding a `ModuleWithProviders` stays whole. For + // `M10` and `M12` ngtsc writes the classes the `forwardRef`s give, + // which can read them before they exist (`LateMod` does): Oxc keeps + // those lists. + let dir = TempDir::new().unwrap(); + fakelib(&dir); + write( + &dir, + "src/third.ts", + r" +import { Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[t]' }) export class ThirdDir {} +@NgModule({}) export class ThirdModule {} +", + ); + write( + &dir, + "src/local.d.ts", + r#" +import * as i0 from '@angular/core'; +export declare class DtsDir { + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare class DtsModule { + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +export declare const DL: readonly [typeof DtsDir, typeof DtsModule]; +"#, + ); + write( + &dir, + "src/dep.ts", + r" +import { Directive, NgModule, forwardRef } from '@angular/core'; +import { ThirdDir, ThirdModule } from './third'; +import { LibDir, LibModule } from 'fakelib'; +@Directive({ selector: '[d]' }) export class Dir {} +@NgModule({ providers: [] }) export class ForRootModule { static forRoot() { return { ngModule: ForRootModule, providers: [] }; } } +@NgModule({}) export default class DefModule {} +export const L3 = [ThirdDir, ThirdModule]; +export const LLIB = [LibDir, LibModule]; +export const LDEF = [Dir, DefModule]; +export const LMWP = [Dir, ForRootModule.forRoot()]; +export const LNEST = [[Dir], [ThirdModule, [LibModule]]]; +export const LFWD = [forwardRef(() => Dir), forwardRef(() => ThirdModule)]; +", + ); + let code = check_modules( + &dir, + r" +import { Directive, NgModule, forwardRef } from '@angular/core'; +import { Dir as D2, L3, LLIB, LDEF, LMWP, LNEST, LFWD } from './dep'; +import { ThirdModule as TM } from './third'; +import { DL } from './local'; +@Directive({ selector: '[ld]' }) export class LocalDir {} +@NgModule({}) export class LocalMod {} +const SAME = [LocalDir, LocalMod]; +const SAME_IMP = [D2, TM]; +const EMPTY: any[] = []; +const SAME_FWD = [forwardRef(() => LocalDir), forwardRef(() => LateMod)]; +@NgModule({ imports: [...SAME] }) export class M1 {} +@NgModule({ imports: [SAME_IMP] }) export class M2 {} +@NgModule({ imports: [[LocalDir, LocalMod], [LocalMod]] }) export class M3 {} +@NgModule({ imports: [...EMPTY, LocalMod] }) export class M4 {} +@NgModule({ imports: [...L3] }) export class M5 {} +@NgModule({ imports: [...LLIB] }) export class M6 {} +@NgModule({ imports: [...LDEF] }) export class M7 {} +@NgModule({ imports: [...LMWP] }) export class M8 {} +@NgModule({ imports: [LNEST] }) export class M9 {} +@NgModule({ imports: [...LFWD] }) export class M10 {} +@NgModule({ imports: [...DL] }) export class M11 {} +@NgModule({ imports: [...SAME_FWD] }) export class M12 {} +@NgModule({ imports: [...L3, TM, ...SAME] }) export class M13 {} +@NgModule({}) export class LateMod {} +", + &[ + ("LocalMod", None), + ("M1", Some("LocalMod")), + ("M2", Some("TM")), + ("M3", Some("LocalMod,[LocalMod]")), + ("M4", Some("EMPTY,LocalMod")), + ("M5", Some("i1.ThirdModule")), + ("M6", Some("i2.LibModule")), + ("M7", Some("i3.default")), + ("M8", Some("LMWP")), + ("M9", Some("i1.ThirdModule,i2.LibModule")), + ("M10", Some("...LFWD")), + ("M11", Some("i4.DtsModule")), + ("M12", Some("...SAME_FWD")), + ("M13", Some("i1.ThirdModule,TM,LocalMod")), + ("LateMod", None), + ], + ); + for code in code { + assert!(code.contains("import * as i1 from './third';"), "{code}"); + assert!(code.contains("import * as i2 from 'fakelib';"), "{code}"); + assert!(code.contains("import * as i3 from './dep';"), "{code}"); + assert!(code.contains("import * as i4 from './local';"), "{code}"); + } + } + + /// `src/third.ts`: a directive and an NgModule. + fn third(dir: &TempDir) { + write( + dir, + "src/third.ts", + r" +import { Directive, NgModule } from '@angular/core'; +@Directive({ selector: '[t]' }) export class ThirdDir {} +@NgModule({}) export class ThirdModule {} +", + ); + } + + #[test] + fn namespace_members_get_their_own_namespace() { + // ngtsc doesn't reuse `third`: it imports `./third` again as `i1`. + let dir = TempDir::new().unwrap(); + third(&dir); + write( + &dir, + "src/dep.ts", + "import { ThirdDir, ThirdModule } from './third';\nexport const L3 = [ThirdDir, ThirdModule];\n", + ); + let code = check_modules( + &dir, + r" +import { Directive, NgModule } from '@angular/core'; +import * as third from './third'; +import * as dep from './dep'; +import { ThirdModule as TM } from './third'; +@Directive({ selector: '[ld]' }) export class LocalDir {} +const NS = [third.ThirdDir, third.ThirdModule]; +@NgModule({ imports: [...NS] }) export class N1 {} +@NgModule({ imports: [...dep.L3] }) export class N2 {} +@NgModule({ imports: [LocalDir, ...NS, third.ThirdModule] }) export class N3 {} +@NgModule({ imports: [[third.ThirdDir, third.ThirdModule]] }) export class N4 {} +@NgModule({ imports: [[LocalDir, TM]] }) export class N5 {} +", + &[ + ("N1", Some("i1.ThirdModule")), + ("N2", Some("i1.ThirdModule")), + ("N3", Some("i1.ThirdModule,third.ThirdModule")), + ("N4", Some("i1.ThirdModule")), + ("N5", Some("TM")), + ], + ); + for code in code { + assert!(code.contains("import * as i1 from './third';"), "{code}"); + } + } + + #[test] + fn exports_are_written_one_by_one() { + let dir = TempDir::new().unwrap(); + third(&dir); + write( + &dir, + "src/dep.ts", + r" +import { Directive, NgModule, Injectable } from '@angular/core'; +import { ThirdDir, ThirdModule } from './third'; +@Injectable() export class Svc {} +@NgModule({}) export class DepModule { static forRoot() { return { ngModule: DepModule, providers: [Svc] }; } } +@NgModule({}) export class OtherModule {} +@Directive({ selector: '[d]' }) export class DepDir {} +export const MODS = [DepModule, OtherModule]; +export const L3 = [ThirdDir, ThirdModule]; +", + ); + check_modules( + &dir, + r" +import { Directive, NgModule } from '@angular/core'; +import * as dep from './dep'; +import * as third from './third'; +import { MODS, DepDir, L3, OtherModule as OM } from './dep'; +import { ThirdModule } from './third'; +@Directive({ selector: '[ld]', standalone: false }) export class LocalDir {} +@NgModule({}) export class LocalMod {} +const MIX = [LocalMod, OM]; +const ALLMOD = [LocalMod, ThirdModule]; +@NgModule({ exports: [dep.OtherModule] }) export class E1 {} +@NgModule({ exports: MODS }) export class E2 {} +@NgModule({ exports: [...MIX, OM] }) export class E3 {} +@NgModule({ declarations: [LocalDir], exports: [LocalDir, ...MODS] }) export class E4 {} +@NgModule({ imports: [DepDir], exports: [DepDir, LocalMod] }) export class E5 {} +@NgModule({ imports: [...ALLMOD], exports: [...ALLMOD] }) export class E6 {} +@NgModule({ imports: [...L3], exports: [...L3] }) export class E7 {} +@NgModule({ imports: [third.ThirdModule], exports: [third.ThirdModule] }) export class E8 {} +", + &[ + ("LocalMod", None), + ("E1", Some("i1.OtherModule")), + ("E2", Some("i1.DepModule,i1.OtherModule")), + ("E3", Some("LocalMod,OM,OM")), + ("E4", Some("i1.DepModule,i1.OtherModule")), + ("E5", Some("LocalMod")), + ("E6", Some("ALLMOD,LocalMod,ThirdModule")), + ("E7", Some("i2.ThirdModule,i2.ThirdModule")), + ("E8", Some("third.ThirdModule,i2.ThirdModule")), + ], + ); + } + + #[test] + fn duplicates_chains_and_component_lists() { + // Duplicates are kept; `CmpModule` drops `XViaEmpty`, which imports + // a component without providers. For `FwdListModule` ngtsc writes + // `[LateMod]`, which reads `LateMod` before it exists: the + // `forwardRef` stays. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Directive, NgModule, Injectable, Component } from '@angular/core'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) export class ProvModule {} +@NgModule({}) export class PlainModule {} +@Directive({ selector: '[d]' }) export class Dir {} +@Component({ selector: 'x-empty', template: '' }) export class XEmpty {} +export const DUP = [ProvModule, Dir, ProvModule]; +export const INNER = [Dir, PlainModule]; +export const CHAIN = [...INNER, ProvModule]; +export const KEPT = [ProvModule, PlainModule]; +export const CMP_LIST = [ProvModule]; +@Component({ selector: 'x-list', template: '', imports: CMP_LIST }) export class XListImports {} +@Component({ selector: 'x-fwd', template: '', imports: [XEmpty] }) export class XViaEmpty {} +", + ); + check_modules( + &dir, + r" +import { NgModule, Directive, forwardRef } from '@angular/core'; +import { DUP, CHAIN, KEPT, XListImports, XViaEmpty } from './dep'; +@Directive({ selector: '[ld]' }) export class LocalDir {} +@NgModule({ imports: [...DUP] }) export class DupModule {} +@NgModule({ imports: [...CHAIN] }) export class ChainModule {} +@NgModule({ imports: KEPT }) export class BareKeptModule {} +@NgModule({ imports: [XListImports, XViaEmpty] }) export class CmpModule {} +@NgModule({ imports: [forwardRef(() => LATER)] }) export class FwdListModule {} +@NgModule({}) export class LateMod {} +export const LATER = [LocalDir, LateMod]; +", + &[ + ("DupModule", Some("i1.ProvModule,i1.ProvModule")), + ("ChainModule", Some("i1.PlainModule,i1.ProvModule")), + ("BareKeptModule", Some("KEPT")), + ("CmpModule", Some("XListImports")), + ("FwdListModule", Some("forwardRef(()=>LATER)")), + ("LateMod", None), + ], + ); + } + + #[test] + fn module_with_providers_lists_stay_whole() { + // A list holding a `ModuleWithProviders`, in any form, is written as + // is: listing its NgModule alone would lose the providers. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Directive, Injectable, NgModule } from '@angular/core'; +@Injectable() export class Svc {} +@NgModule({}) export class RootModule { static forRoot() { return { ngModule: RootModule, providers: [Svc] }; } } +@Directive({ selector: '[d]' }) export class Dir {} +export const ROOT = RootModule.forRoot(); +export const L_CALL = [Dir, RootModule.forRoot()]; +export const L_LIT = [Dir, { ngModule: RootModule, providers: [Svc] }]; +export const L_CONST = [Dir, ROOT]; +export const L_NEST = [Dir, [L_CALL]]; +", + ); + check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { L_CALL, L_LIT, L_CONST, L_NEST } from './dep'; +@NgModule({ imports: [...L_CALL] }) export class CallModule {} +@NgModule({ imports: [...L_LIT] }) export class LitModule {} +@NgModule({ imports: [L_CONST] }) export class ConstModule {} +@NgModule({ imports: [...L_NEST] }) export class NestModule {} +", + &[ + ("CallModule", Some("L_CALL")), + ("LitModule", Some("L_LIT")), + ("ConstModule", Some("L_CONST")), + ("NestModule", Some("L_NEST")), + ], + ); + } + + #[test] + fn a_class_that_cannot_be_imported_keeps_its_list() { + // `Hidden` is not exported: ngtsc fails ("Unable to import class + // Hidden"). The list stays as written. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Directive, NgModule, Injectable } from '@angular/core'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) class Hidden {} +@Directive({ selector: '[d]' }) export class Dir {} +export const HID = [Dir, Hidden]; +", + ); + check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { HID } from './dep'; +@NgModule({ imports: [...HID] }) export class HidModule {} +", + &[("HidModule", Some("...HID"))], + ); + } + + #[test] + fn namespace_names_avoid_the_file_bindings() { + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/dep.ts", + r" +import { Directive, NgModule } from '@angular/core'; +@NgModule({}) export class PlainModule {} +@Directive({ selector: '[d]' }) export class Dir {} +export const LIST = [Dir, PlainModule]; +", + ); + let code = check_modules( + &dir, + r" +import { NgModule } from '@angular/core'; +import { LIST } from './dep'; +export const i1 = 'user'; +@NgModule({ imports: [...LIST] }) export class M {} +", + &[("M", Some("i1_1.PlainModule"))], + ); + for code in code { + assert!(code.contains("import * as i1_1 from './dep';"), "{code}"); + } + } + + #[test] + fn a_library_class_is_named_through_its_entry_point() { + // `@ws/lib` is a workspace library behind a tsconfig path. ngtsc + // names `InnerModule` through it, and fails ("Unable to import class + // InnerModule") when the entry point doesn't export it: the list + // stays as written then. + for (exports, expected) in + [("LIST, Dir, InnerModule", "i1.InnerModule"), ("LIST, Dir", "...LIST")] + { + let dir = TempDir::new().unwrap(); + write( + &dir, + "libs/lib/inner.ts", + r" +import { Directive, NgModule, Injectable } from '@angular/core'; +@Injectable() export class Svc {} +@NgModule({ providers: [Svc] }) export class InnerModule {} +@Directive({ selector: '[d]' }) export class Dir {} +export const LIST = [Dir, InnerModule]; +", + ); + write(&dir, "libs/lib/index.ts", &format!("export {{ {exports} }} from './inner';\n")); + let tsconfig = write( + &dir, + "tsconfig.json", + r#"{ "compilerOptions": { "paths": { "@ws/lib": ["./libs/lib/index.ts"] } } }"#, + ); + let source = r" +import { NgModule } from '@angular/core'; +import { LIST } from '@ws/lib'; +@NgModule({ imports: [...LIST] }) export class M {} +"; + let path = write(&dir, "src/app.ts", source); + let options = TransformOptions { tsconfig_path: Some(tsconfig), ..cross_file() }; + assert_module_imports( + path.to_str().unwrap(), + source, + &[("M", Some(expected))], + &options, + ); + } + } + + #[test] + fn files_a_component_imports_are_dependencies() { + // Whether `Cmp` stays depends on `./plain`, which `app.ts` doesn't + // import. + let dir = TempDir::new().unwrap(); + write( + &dir, + "src/plain.ts", + "import { NgModule } from '@angular/core';\n@NgModule({}) export class PlainModule {}\n", + ); + write( + &dir, + "src/cmp.ts", + r" +import { Component } from '@angular/core'; +import { PlainModule } from './plain'; +@Component({ selector: 'c', template: '', imports: [PlainModule] }) export class Cmp {} +", + ); + let source = r" +import { NgModule } from '@angular/core'; +import { Cmp } from './cmp'; +@NgModule({ imports: [Cmp] }) export class M {} +"; + let path = write(&dir, "src/app.ts", source); + let allocator = Allocator::default(); + let result = transform_angular_file( + &allocator, + path.to_str().unwrap(), + source, + Some(&cross_file()), + None, + ); + assert_eq!(injectors(&result.code), vec!["/*@__PURE__*/i0.ɵɵdefineInjector({})"]); + assert!( + result.dependencies.iter().any(|dep| dep.ends_with("plain.ts")), + "{:?}", + result.dependencies + ); + } + #[test] fn files_read_are_dependencies() { let dir = TempDir::new().unwrap(); diff --git a/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs b/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs index 8fca07562..19a16ae0d 100644 --- a/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs +++ b/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs @@ -1,8 +1,10 @@ -//! `ModuleWithProviders` entries in a partial `ɵɵngDeclareNgModule`'s -//! `imports` (#623). ngtsc lists the NgModule each one names -//! (`resolveTypeList`): `imports: [mwp, Holder.forRoot()]` becomes -//! `imports: [DepModule, DepModule]`. The injector and the full-mode -//! `ɵɵsetNgModuleScope` keep the entries as written. +//! The lists of a partial `ɵɵngDeclareNgModule` (#623). ngtsc lists what +//! each entry gives (`resolveTypeList`): `imports: [mwp, Holder.forRoot()]` +//! becomes `imports: [DepModule, DepModule]`, and spreads, nested arrays, +//! `const` lists and namespace members are flattened. The injector and the +//! full-mode `ɵɵsetNgModuleScope` keep the entries as written. In both +//! modes the lists are wrapped in `() => [...]` when one names a class this +//! file declares after the NgModule. //! //! Each expected list below was checked against ngtsc 22.2.3 on the same //! input. An entry oxc can't settle keeps its old output. @@ -125,10 +127,11 @@ export class AppModule {} #[test] fn unsure_entries_keep_their_old_output() { - // - `later`: the module is declared after AppModule, so naming it would - // read it before it exists (ngtsc wraps the list in `() => [...]`). + // - `later`: the module is declared after AppModule, so ngtsc lists it + // and wraps the lists in `() => [...]`. // - `typeOnly`: `DepModule` is only a type here, so its import is - // removed; ngtsc names it anyway and drops the import. + // removed; ngtsc names it anyway and drops the import. Oxc keeps it + // as before. let code = partial( r" import { NgModule, ModuleWithProviders } from '@angular/core'; @@ -143,7 +146,7 @@ export class AppModule {} export class LaterModule {} ", ); - assert!(code.contains("type:AppModule,imports:[Holder,Holder]}"), "{code}"); + assert!(code.contains("type:AppModule,imports:()=>[LaterModule,Holder]}"), "{code}"); assert!(!code.contains("'./dep'"), "the type-only import stays removed:\n{code}"); } @@ -262,6 +265,340 @@ export class AppModule {} assert!(code.contains("type:AppModule,imports:[mwp,OtherModuleHolder]}"), "{code}"); } +fn full(source: &str) -> String { + compile_in("app.ts", source, &TransformOptions::default()) +} + +/// `Holder.forRoot()` gives `LaterModule`, declared after AppModule. +const LATER: &str = r" +import { NgModule, ModuleWithProviders, Component } from '@angular/core'; +@Component({ selector: 'app-c', template: 'x', standalone: false }) +export class AppCmp {} +export class Holder { + static forRoot(): ModuleWithProviders { return { ngModule: LaterModule, providers: [] }; } +} +@NgModule({}) +export class EarlyModule {} +@NgModule({ declarations: [AppCmp], imports: [EarlyModule, Holder.forRoot()], exports: [AppCmp], bootstrap: [AppCmp] }) +export class AppModule {} +@NgModule({}) +export class LaterModule {} +"; + +#[test] +fn a_module_declared_later_wraps_every_list() { + // ngtsc wraps all four lists once one names a class declared after the + // NgModule, which isn't initialized yet when `ɵmod` is created. + let code = partial(LATER); + assert!( + code.contains( + "type:AppModule,bootstrap:()=>[AppCmp],declarations:()=>[AppCmp],imports:()=>[EarlyModule,LaterModule],exports:()=>[AppCmp]}" + ), + "{code}" + ); + assert!(code.contains("type:AppModule,imports:[EarlyModule,Holder.forRoot()]}"), "{code}"); +} + +#[test] +fn a_return_type_naming_a_later_module_wraps_the_list() { + let code = partial( + r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +export class Holder { + static typed(): ModuleWithProviders { const p: any[] = []; return { ngModule: null as any, providers: p }; } +} +@NgModule({ imports: [Holder.typed()] }) +export class AppModule {} +@NgModule({}) +export class LaterModule {} +", + ); + assert!(code.contains("type:AppModule,imports:()=>[LaterModule]}"), "{code}"); +} + +#[test] +fn full_mode_wraps_lists_like_ngtsc_and_keeps_raw_ones() { + // ngtsc full: `bootstrap: () => [AppCmp]` and + // `{ declarations: () => [AppCmp], imports: () => [EarlyModule, LaterModule], exports: () => [AppCmp] }`. + // The raw `imports` stays as written and unwrapped: the runtime only + // flattens and unwraps a list that is an array (`convertToTypeArray`). + let code = full(LATER); + assert!(code.contains("ɵɵdefineNgModule({type:AppModule,bootstrap:()=>[AppCmp]})"), "{code}"); + assert!( + code.contains( + "ɵɵsetNgModuleScope(AppModule,{declarations:()=>[AppCmp],imports:[EarlyModule,Holder.forRoot()],exports:()=>[AppCmp]})" + ), + "{code}" + ); +} + +/// `forwardRef` to classes declared before the NgModule, and to an import. +const FORWARD_REFS: &str = r" +import { NgModule, Component, forwardRef } from '@angular/core'; +import { DepModule } from './dep'; +@NgModule({}) +export class EarlyModule {} +@Component({ selector: 'b', template: '', standalone: false }) +export class BCmp {} +@NgModule({ declarations: [BCmp], imports: [forwardRef(() => EarlyModule)], exports: [forwardRef(() => BCmp)] }) +export class AllEarly {} +@NgModule({ imports: [forwardRef(() => DepModule)] }) +export class ViaImport {} +"; + +#[test] +fn a_forward_ref_to_an_earlier_class_is_not_wrapped() { + // ngtsc wraps by where the class is declared, not by `forwardRef`. An + // import may be in a cycle with this file, so without cross-file + // analysis its wrap stays (ngtsc: `imports: [DepModule]`). + let code = partial(FORWARD_REFS); + assert!( + code.contains("type:AllEarly,declarations:[BCmp],imports:[EarlyModule],exports:[BCmp]}"), + "{code}" + ); + assert!(code.contains("type:ViaImport,imports:()=>[DepModule]}"), "{code}"); + let code = full(FORWARD_REFS); + assert!( + code.contains( + "ɵɵsetNgModuleScope(AllEarly,{declarations:[BCmp],imports:[EarlyModule],exports:[BCmp]})" + ), + "{code}" + ); + assert!(code.contains("ɵɵsetNgModuleScope(ViaImport,{imports:()=>[DepModule]})"), "{code}"); +} + +/// `CycCmp` comes from `cyc.ts`, which imports this file back. +const APP_EARLY_FORWARD_REF: &str = r" +import { NgModule, Component, forwardRef } from '@angular/core'; +import { CycCmp } from './cyc'; +@Component({ selector: 'e', template: '', standalone: false }) +export class EarlyCmp {} +export const TITLE = 'x'; +@NgModule({ declarations: [forwardRef(() => EarlyCmp), CycCmp] }) +export class AppModule {} +"; + +#[cfg_attr(not(feature = "cross_file_elision"), expect(dead_code))] +const CYCLIC_CMP: &str = r" +import { Component } from '@angular/core'; +import { TITLE } from './app'; +@Component({ selector: 'c', template: '', standalone: false }) +export class CycCmp { t = TITLE; } +"; + +#[test] +fn a_forward_ref_keeps_the_wrap_next_to_an_unread_import() { + // A `forwardRef` wrapped every list before. Without cross-file analysis + // oxc can't tell whether `./cyc` loads this file back, so the wrap + // stays (ngtsc: `declarations: [EarlyCmp, CycCmp]`). + let code = partial(APP_EARLY_FORWARD_REF); + assert!(code.contains("type:AppModule,declarations:()=>[EarlyCmp,CycCmp]}"), "{code}"); + let code = full(APP_EARLY_FORWARD_REF); + assert!( + code.contains("ɵɵsetNgModuleScope(AppModule,{declarations:()=>[EarlyCmp,CycCmp]})"), + "{code}" + ); +} + +#[test] +fn spreads_nested_arrays_and_const_lists_are_flattened() { + let code = partial( + r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +@NgModule({}) +export class AModule { + static forRoot(): ModuleWithProviders { return { ngModule: AModule, providers: [] }; } +} +@NgModule({}) +export class BModule {} +export class Holder { + static forRoot(): ModuleWithProviders { return { ngModule: BModule, providers: [] }; } +} +const mwp = Holder.forRoot(); +const MODS = [AModule.forRoot(), BModule, mwp]; +const NESTED = [MODS, [Holder.forRoot()]]; +@NgModule({ imports: [...MODS, AModule] }) +export class SpreadModule {} +@NgModule({ imports: [[Holder.forRoot(), AModule], BModule] }) +export class NestedModule {} +@NgModule({ imports: MODS }) +export class BareModule {} +@NgModule({ imports: [NESTED, ...[mwp]] }) +export class DeepModule {} +@NgModule({ imports: [AModule], exports: [...(MODS as any[])] }) +export class ExportSpreadModule {} +", + ); + assert!( + code.contains("type:SpreadModule,imports:[AModule,BModule,BModule,AModule]}"), + "{code}" + ); + assert!(code.contains("type:NestedModule,imports:[BModule,AModule,BModule]}"), "{code}"); + assert!(code.contains("type:BareModule,imports:[AModule,BModule,BModule]}"), "{code}"); + assert!( + code.contains("type:DeepModule,imports:[AModule,BModule,BModule,BModule,BModule]}"), + "{code}" + ); + assert!( + code.contains( + "type:ExportSpreadModule,imports:[AModule],exports:[AModule,BModule,BModule]}" + ), + "{code}" + ); + // `ɵinj`, as ngtsc writes it: a list holding a `ModuleWithProviders` is + // written as is, without its spread. In `exports` such a list is a type + // error (here cast away): Oxc keeps it, with its providers, where ngtsc + // writes `[AModule, AModule, BModule, BModule]`. + let injectors: Vec<&str> = code + .split("ɵɵngDeclareInjector(") + .skip(1) + .map(|rest| { + let init = &rest[..rest.find(");").unwrap()]; + &init[init.find("type:").unwrap()..] + }) + .collect(); + assert_eq!( + injectors, + [ + "type:AModule}", + "type:BModule}", + "type:SpreadModule,imports:[MODS,AModule]}", + "type:NestedModule,imports:[[Holder.forRoot(),AModule],BModule]}", + "type:BareModule,imports:[MODS]}", + "type:DeepModule,imports:[NESTED,[mwp]]}", + "type:ExportSpreadModule,imports:[AModule,...(MODS)]}", + ], + "{code}" + ); +} + +#[test] +fn a_later_class_in_a_const_list_wraps_the_lists() { + // ngtsc partial, for each module: `imports: () => [EarlyModule, + // LaterModule]` (through a `ModuleWithProviders` and a `forwardRef` in a + // list), `imports: [EarlyModule, EarlyModule]` (an alias), and + // `declarations: () => [ACmp, LaterCmp], exports: () => [ACmp, LaterCmp]`. + let code = partial( + r" +import { NgModule, ModuleWithProviders, Component, forwardRef } from '@angular/core'; +export class Holder { + static forRoot(): ModuleWithProviders { return { ngModule: LaterModule, providers: [] }; } +} +@NgModule({}) +export class EarlyModule {} +@Component({ selector: 'a', template: '', standalone: false }) +export class ACmp {} +const VIA_MWP = [EarlyModule, Holder.forRoot()]; +const VIA_FORWARD_REF = [EarlyModule, forwardRef(() => LaterModule)]; +const ALIAS = EarlyModule; +const DECLS = [ACmp, forwardRef(() => LaterCmp)]; +@NgModule({ imports: [...VIA_MWP] }) +export class MwpModule {} +@NgModule({ imports: [...VIA_FORWARD_REF] }) +export class ForwardRefModule {} +@NgModule({ imports: [ALIAS, ...[ALIAS]] }) +export class AliasModule {} +@NgModule({ declarations: [...DECLS], exports: [...DECLS] }) +export class DeclModule {} +@NgModule({}) +export class LaterModule {} +@Component({ selector: 'l', template: '', standalone: false }) +export class LaterCmp {} +", + ); + assert!(code.contains("type:MwpModule,imports:()=>[EarlyModule,LaterModule]}"), "{code}"); + assert!( + code.contains("type:ForwardRefModule,imports:()=>[EarlyModule,LaterModule]}"), + "{code}" + ); + assert!(code.contains("type:AliasModule,imports:[EarlyModule,EarlyModule]}"), "{code}"); + assert!( + code.contains( + "type:DeclModule,declarations:()=>[ACmp,LaterCmp],exports:()=>[ACmp,LaterCmp]}" + ), + "{code}" + ); +} + +#[test] +fn an_unread_entry_keeps_its_name_next_to_a_later_class() { + // ngtsc partial: `imports: () => [Unknown, LaterModule, LaterModule]`. + // Without cross-file analysis `Unknown` keeps its old output. + let code = partial( + r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { Unknown } from './dep'; +export class Holder { + static typed(): ModuleWithProviders { const p: any[] = []; return { ngModule: null as any, providers: p }; } +} +const L = [Holder.typed()]; +@NgModule({ imports: [Unknown, ...L, ...(L as any[])] }) +export class AppModule {} +@NgModule({}) +export class LaterModule {} +", + ); + assert!( + code.contains("type:AppModule,imports:()=>[Unknown,LaterModule,LaterModule]}"), + "{code}" + ); +} + +#[test] +fn lists_oxc_cant_read_keep_their_old_output() { + // - an imported name inside a list, or a namespace member, without + // cross-file analysis (ngtsc: `[DepModule, i1.DepModule]`); + // - a spread of a value that is no array, and `satisfies`, which ngtsc + // rejects (error -991010); + // - a `let` list, a function call and a condition, which ngtsc reads + // (`[AModule, AModule, AModule]`). + let code = partial( + r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +import { DepModule } from './dep'; +import * as ns from './dep'; +@NgModule({}) +export class AModule { + static forRoot(): ModuleWithProviders { return { ngModule: AModule, providers: [] }; } +} +const LOCAL = [DepModule]; +const ONE = AModule.forRoot(); +let LET_LIST = [AModule]; +function make() { return AModule; } +function cond() { return true; } +@NgModule({ imports: [...LOCAL, ns.DepModule] }) +export class ImportedModule {} +@NgModule({ imports: [...(ONE as any)] }) +export class OneModule {} +@NgModule({ imports: [...([AModule] satisfies any[])] }) +export class SatisfiesModule {} +@NgModule({ imports: [...LET_LIST, [make()], ...(cond() ? [AModule] : [])] }) +export class EvaluatedModule {} +", + ); + for module in ["ImportedModule", "OneModule", "SatisfiesModule", "EvaluatedModule"] { + assert!(code.contains(&format!("type:{module}}})")), "{module}:\n{code}"); + } +} + +#[test] +fn a_spread_in_a_class_in_a_function_keeps_its_old_output() { + let code = partial( + r" +import { NgModule } from '@angular/core'; +@NgModule({}) +export class DepModule {} +const MODS = [DepModule]; +export function make() { + @NgModule({ imports: [...MODS] }) + class AppModule {} + return AppModule; +} +", + ); + assert!(code.contains("type:AppModule})"), "{code}"); +} + #[cfg(feature = "cross_file_elision")] mod cross_file { use super::*; @@ -310,11 +647,10 @@ export class AppModule {} "; #[test] - fn a_module_declared_later_in_this_file_keeps_its_old_output() { + fn a_module_declared_later_in_this_file_wraps_the_list() { // Another file's `ModuleWithProviders` names a class this file - // declares after the NgModule. Listing `Later` would read it before it - // is initialized (ngtsc wraps the list in `() => [...]`), so the - // entry stays as written. + // declares after the NgModule. Like ngtsc, it is listed in + // `() => [...]`, so it is read once it is initialized. let dir = TempDir::new().unwrap(); write( &dir, @@ -344,8 +680,7 @@ export class Later {} ", CompilationMode::Partial, ); - assert!(code.contains("type:M,imports:[Holder,LATER_ROOT]}"), "{code}"); - assert!(!code.contains("imports:[Later"), "{code}"); + assert!(code.contains("type:M,imports:()=>[Later,Later]}"), "{code}"); } #[test] @@ -593,4 +928,499 @@ export class AppModule {} assert!(code.contains("type:AppModule,imports:[Holder,Holder,DepModule]}"), "{code}"); assert!(!code.contains("import*asi1"), "{code}"); } + + /// A module, a holder, and lists of them, for imports by name and + /// through a namespace. + const DEP_LISTS: &str = r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +@NgModule({}) +export class DepModule { + static forRoot(): ModuleWithProviders { return { ngModule: DepModule, providers: [] }; } +} +@NgModule({}) +export class OtherModule {} +export class Holder { + static forRoot(): ModuleWithProviders { return { ngModule: OtherModule, providers: [] }; } +} +export const mwpConst = DepModule.forRoot(); +export const litConst: ModuleWithProviders = { ngModule: OtherModule, providers: [] }; +export const DEP_MODS = [DepModule.forRoot(), OtherModule]; +export const NS_LIST = [OtherModule]; +"; + + #[test] + fn namespace_members_name_the_module_through_an_import() { + // ngtsc adds `import * as i1 from "./dep"` next to `ns`. The + // injector keeps the entries as written. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_LISTS); + let source = r" +import { NgModule } from '@angular/core'; +import * as ns from './dep'; +const localViaNs = ns.Holder.forRoot(); +@NgModule({ imports: [ns.DepModule.forRoot(), ns.Holder.forRoot(), ns.mwpConst, ns.litConst, localViaNs, ns.OtherModule] }) +export class AppModule {} +"; + let code = compile(&dir, "app.ts", source, CompilationMode::Partial); + assert!(code.contains("import*asi1from'./dep';"), "{code}"); + assert!( + code.contains( + "type:AppModule,imports:[i1.DepModule,i1.OtherModule,i1.DepModule,i1.OtherModule,i1.OtherModule,i1.OtherModule]}" + ), + "{code}" + ); + assert!( + code.contains( + "type:AppModule,imports:[ns.DepModule.forRoot(),ns.Holder.forRoot(),ns.mwpConst,ns.litConst,localViaNs,ns.OtherModule]}" + ), + "ɵinj should keep the entries as written:\n{code}" + ); + // Without cross-file analysis, as before. + let code = partial(source); + assert!(code.contains("type:AppModule,imports:[localViaNs]}"), "{code}"); + } + + #[test] + fn imported_lists_are_flattened() { + // A name this file writes stays as written; a class read in + // `dep.ts` or through `ns` gets `i1.`. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_LISTS); + let source = r" +import { NgModule, Component } from '@angular/core'; +import { DepModule, OtherModule, DEP_MODS } from './dep'; +import * as ns from './dep'; +@Component({ selector: 'sa', template: '', standalone: true }) +export class Standalone {} +const LOCAL = [DepModule, OtherModule, Standalone]; +const WITH_NS = [ns.DepModule, ns.NS_LIST, DEP_MODS]; +const LOCAL_MWP = [DepModule.forRoot()]; +@NgModule({ imports: [...DEP_MODS, DepModule] }) +export class SpreadModule {} +@NgModule({ imports: [...ns.DEP_MODS] }) +export class NsModule {} +@NgModule({ imports: [[DepModule.forRoot()]] }) +export class NestedImported {} +@NgModule({ imports: [...LOCAL, WITH_NS, ...LOCAL_MWP, ns.OtherModule] }) +export class MixedModule {} +@NgModule({ imports: [ns.DEP_MODS, [ns.DepModule.forRoot()]], exports: [ns.OtherModule] }) +export class NsOnly {} +"; + let code = compile(&dir, "app.ts", source, CompilationMode::Partial); + assert!( + code.contains("type:SpreadModule,imports:[i1.DepModule,i1.OtherModule,DepModule]}"), + "{code}" + ); + assert!(code.contains("type:NsModule,imports:[i1.DepModule,i1.OtherModule]}"), "{code}"); + assert!(code.contains("type:NestedImported,imports:[i1.DepModule]}"), "{code}"); + assert!( + code.contains( + "type:MixedModule,imports:[DepModule,OtherModule,Standalone,i1.DepModule,i1.OtherModule,i1.DepModule,i1.OtherModule,i1.DepModule,i1.OtherModule]}" + ), + "{code}" + ); + assert!( + code.contains( + "type:NsOnly,imports:[i1.DepModule,i1.OtherModule,i1.DepModule],exports:[i1.OtherModule]}" + ), + "{code}" + ); + // Without cross-file analysis an imported name inside a list is + // unknown, so those entries keep their old output. + let code = partial(source); + assert!(code.contains("type:SpreadModule,imports:[DepModule]}"), "{code}"); + assert!(code.contains("type:MixedModule,imports:[WITH_NS]}"), "{code}"); + assert!(code.contains("type:NsOnly})"), "{code}"); + } + + #[test] + fn values_oxc_does_not_evaluate_keep_their_old_output() { + // ngtsc also reads the `let` list and the function call: + // `[AModule, i1.OtherModule, i1.OtherModule, AModule, AModule]`, and + // `[AModule]` for the condition. + let dir = TempDir::new().unwrap(); + write( + &dir, + "dep.ts", + r" +import { NgModule, ModuleWithProviders } from '@angular/core'; +@NgModule({}) +export class DepModule {} +@NgModule({}) +export class OtherModule {} +export const LIT: ModuleWithProviders = { ngModule: OtherModule, providers: [] }; +export const LIT_LIST = [{ ngModule: OtherModule, providers: [] }]; +", + ); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import { LIT, LIT_LIST, DepModule } from './dep'; +@NgModule({}) +export class AModule {} +let LET_LIST = [AModule]; +@NgModule({ imports: [{ ngModule: AModule, providers: [] }, LIT, ...LIT_LIST, ...LET_LIST, [forwardRefLess()]] }) +export class AppModule {} +function forwardRefLess() { return AModule; } +@NgModule({ imports: [...(cond() ? [AModule] : [DepModule])] }) +export class CondModule {} +function cond() { return true; } +", + CompilationMode::Partial, + ); + assert!( + code.contains("type:AppModule,imports:[AModule,i1.OtherModule,i1.OtherModule]}"), + "{code}" + ); + assert!(code.contains("type:CondModule})"), "{code}"); + } + + #[test] + fn namespaces_are_numbered_in_list_order() { + // ngtsc: `import * as i1 from "./dep2"; import * as i2 from "./dep"`. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_LISTS); + write( + &dir, + "dep2.ts", + r" +import { NgModule } from '@angular/core'; +@NgModule({}) +export class TwoModule {} +export const L2 = [TwoModule]; +", + ); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import * as ns1 from './dep'; +import * as ns2 from './dep2'; +@NgModule({ imports: [...ns2.L2, ...ns1.DEP_MODS] }) +export class AppModule {} +", + CompilationMode::Partial, + ); + assert!(code.contains("import*asi1from'./dep2';import*asi2from'./dep';"), "{code}"); + assert!( + code.contains("type:AppModule,imports:[i1.TwoModule,i2.DepModule,i2.OtherModule]}"), + "{code}" + ); + } + + #[test] + fn a_class_is_named_by_its_export_from_its_own_file() { + // ngtsc names `Inner` by its export `Public` of `./dep`, even + // through a barrel. A class that isn't exported can't be named + // (ngtsc error -993004): that list keeps its old output. + let dir = TempDir::new().unwrap(); + write( + &dir, + "dep.ts", + r" +import { NgModule } from '@angular/core'; +@NgModule({}) +class Inner {} +export { Inner as Public }; +export const DEP_MODS = [Inner]; +@NgModule({}) +class Hidden {} +export const HIDDEN_MODS = [Hidden]; +", + ); + write(&dir, "barrel.ts", "export * from './dep';\n"); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import { DEP_MODS, HIDDEN_MODS } from './dep'; +import * as b from './barrel'; +@NgModule({ imports: [...DEP_MODS] }) +export class AppModule {} +@NgModule({ imports: [b.Public, ...b.DEP_MODS] }) +export class BarrelModule {} +@NgModule({ imports: [...HIDDEN_MODS] }) +export class HiddenModule {} +", + CompilationMode::Partial, + ); + assert!(code.contains("import*asi1from'./dep';"), "{code}"); + assert!(!code.contains("import*asi2"), "{code}"); + assert!(code.contains("type:AppModule,imports:[i1.Public]}"), "{code}"); + assert!(code.contains("type:BarrelModule,imports:[i1.Public,i1.Public]}"), "{code}"); + assert!(code.contains("type:HiddenModule})"), "{code}"); + } + + #[test] + fn an_aliased_import_in_a_list_stays_as_written() { + // ngtsc partial: `imports: [D, i1.DepModule, D]`. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_LISTS); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import { DepModule as D } from './dep'; +const LOCAL = [D, D.forRoot()]; +@NgModule({ imports: [...LOCAL, [D]] }) +export class AppModule {} +", + CompilationMode::Partial, + ); + assert!(code.contains("type:AppModule,imports:[D,i1.DepModule,D]}"), "{code}"); + } + + #[test] + fn an_import_specifier_is_kept_as_written() { + // ngtsc writes `"./dep"`; oxc keeps the file's own `./dep.js`. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_LISTS); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import * as ns from './dep.js'; +@NgModule({ imports: [...ns.NS_LIST] }) +export class AppModule {} +", + CompilationMode::Partial, + ); + assert!(code.contains("import*asi1from'./dep.js';"), "{code}"); + assert!(code.contains("type:AppModule,imports:[i1.OtherModule]}"), "{code}"); + } + + const APP_FORWARD_REFS: &str = r" +import { NgModule, forwardRef } from '@angular/core'; +import { DepModule, DEP_MODS, PLAIN_MODS } from './dep'; +@NgModule({ imports: [forwardRef(() => DepModule)] }) +export class AppModule {} +@NgModule({ imports: [...DEP_MODS] }) +export class ListModule {} +@NgModule({ imports: [...PLAIN_MODS] }) +export class PlainListModule {} +"; + + const DEP_FORWARD_REFS: &str = r" +import { NgModule, forwardRef } from '@angular/core'; +@NgModule({}) +export class DepModule {} +@NgModule({}) +export class OtherModule {} +export const DEP_MODS = [forwardRef(() => OtherModule)]; +export const PLAIN_MODS = [OtherModule]; +"; + + #[test] + fn a_forward_ref_to_another_file_is_not_wrapped() { + // ngtsc partial: `[DepModule]`, `[i1.OtherModule]`, `[i1.OtherModule]`. + let dir = TempDir::new().unwrap(); + write(&dir, "dep.ts", DEP_FORWARD_REFS); + let code = compile(&dir, "app.ts", APP_FORWARD_REFS, CompilationMode::Partial); + assert!(code.contains("type:AppModule,imports:[DepModule]}"), "{code}"); + assert!(code.contains("type:ListModule,imports:[i1.OtherModule]}"), "{code}"); + assert!(code.contains("type:PlainListModule,imports:[i1.OtherModule]}"), "{code}"); + let code = compile(&dir, "app.ts", APP_FORWARD_REFS, CompilationMode::Full); + assert!(code.contains("ɵɵsetNgModuleScope(AppModule,{imports:[DepModule]})"), "{code}"); + } + + #[test] + fn a_forward_ref_to_a_file_that_loads_this_one_stays_wrapped() { + // `dep.ts` imports this file back, so it may not have run when this + // file defines its NgModules. ngtsc reads the classes in place + // (`[DepModule]`, `[i1.OtherModule]`); oxc keeps them lazy where a + // `forwardRef` asked for it. A list without one is read in place. + let dir = TempDir::new().unwrap(); + write( + &dir, + "dep.ts", + &format!( + "{DEP_FORWARD_REFS}import {{ AppModule }} from './app';\nexport const USE = AppModule;\n" + ), + ); + let code = compile(&dir, "app.ts", APP_FORWARD_REFS, CompilationMode::Partial); + assert!(code.contains("type:AppModule,imports:()=>[DepModule]}"), "{code}"); + assert!(code.contains("type:ListModule,imports:()=>[i1.OtherModule]}"), "{code}"); + assert!(code.contains("type:PlainListModule,imports:[i1.OtherModule]}"), "{code}"); + let code = compile(&dir, "app.ts", APP_FORWARD_REFS, CompilationMode::Full); + assert!(code.contains("ɵɵsetNgModuleScope(AppModule,{imports:()=>[DepModule]})"), "{code}"); + } + + #[test] + fn a_forward_ref_through_a_barrel_that_loads_this_one_stays_wrapped() { + // The class is imported through `./index`, which loads this file + // first, so it isn't defined yet when this file runs. ngtsc gives + // `declarations: [FooCmp], exports: [FooCmp]`, which reads it too + // early; oxc keeps the lists lazy. + let dir = TempDir::new().unwrap(); + write( + &dir, + "index.ts", + "export * from './feature.module';\nexport * from './foo.component';\n", + ); + write( + &dir, + "foo.component.ts", + "import { Component } from '@angular/core';\n@Component({ selector: 'foo', template: '', standalone: false })\nexport class FooCmp {}\n", + ); + let source = r" +import { NgModule, forwardRef } from '@angular/core'; +import { FooCmp } from './index'; +@NgModule({ declarations: [forwardRef(() => FooCmp)], exports: [forwardRef(() => FooCmp)] }) +export class FeatureModule {} +"; + let code = compile(&dir, "feature.module.ts", source, CompilationMode::Partial); + assert!( + code.contains("type:FeatureModule,declarations:()=>[FooCmp],exports:()=>[FooCmp]}"), + "{code}" + ); + let code = compile(&dir, "feature.module.ts", source, CompilationMode::Full); + assert!( + code.contains( + "ɵɵsetNgModuleScope(FeatureModule,{declarations:()=>[FooCmp],exports:()=>[FooCmp]})" + ), + "{code}" + ); + } + + #[test] + fn a_plain_entry_from_a_file_that_loads_this_one_keeps_the_wrap() { + // A `forwardRef` wrapped every list before. `cyc.ts` loads this file + // back, so `CycCmp` may not be defined yet: the wrap stays (ngtsc: + // `declarations: [EarlyCmp, CycCmp]`). + let dir = TempDir::new().unwrap(); + write(&dir, "cyc.ts", CYCLIC_CMP); + let code = compile(&dir, "app.ts", APP_EARLY_FORWARD_REF, CompilationMode::Partial); + assert!(code.contains("type:AppModule,declarations:()=>[EarlyCmp,CycCmp]}"), "{code}"); + let code = compile(&dir, "app.ts", APP_EARLY_FORWARD_REF, CompilationMode::Full); + assert!( + code.contains("ɵɵsetNgModuleScope(AppModule,{declarations:()=>[EarlyCmp,CycCmp]})"), + "{code}" + ); + // When it doesn't, the lists are read in place like ngtsc's. + write( + &dir, + "cyc.ts", + &CYCLIC_CMP.replace("import { TITLE } from './app';", "const TITLE = 'x';"), + ); + let code = compile(&dir, "app.ts", APP_EARLY_FORWARD_REF, CompilationMode::Partial); + assert!(code.contains("type:AppModule,declarations:[EarlyCmp,CycCmp]}"), "{code}"); + } + + #[test] + fn a_return_type_can_import_a_new_file() { + // The holder's return type names a module this file never imports. + // ngtsc imports it: `import * as i1 from "./dep"`, `[i1.DepModule]`. + let dir = TempDir::new().unwrap(); + write( + &dir, + "holder.ts", + r" +import { ModuleWithProviders } from '@angular/core'; +import type { DepModule } from './dep'; +export class Holder { + static forRoot(): ModuleWithProviders { const p: any[] = []; return { ngModule: null as any, providers: p }; } +} +", + ); + write( + &dir, + "dep.ts", + "import { NgModule } from '@angular/core';\n@NgModule({})\nexport class DepModule {}\n", + ); + let code = compile( + &dir, + "app.ts", + r" +import { NgModule } from '@angular/core'; +import { Holder } from './holder'; +@NgModule({ imports: [Holder.forRoot()] }) +export class AppModule {} +", + CompilationMode::Partial, + ); + assert!(code.contains("import*asi1from'./dep';"), "{code}"); + assert!(code.contains("type:AppModule,imports:[i1.DepModule]}"), "{code}"); + } + + #[test] + fn a_list_from_a_file_that_loads_this_one_keeps_the_wrap() { + // `cyc.ts` loads this file back, so `LIST` may not be defined yet + // when the NgModule is: full compilation lists `LIST` as written, so + // the wrap a `forwardRef` asked for stays (ngtsc full writes + // `[EarlyCmp, i1.OtherCmp]`). Partial keeps it too. + let dir = TempDir::new().unwrap(); + write( + &dir, + "cyc.ts", + "import { TITLE } from './app';\nimport { OtherCmp } from './other';\nexport const LIST = [OtherCmp];\nexport const T2 = TITLE;\n", + ); + write( + &dir, + "other.ts", + "import { Component } from '@angular/core';\n@Component({ selector: 'o', template: '', standalone: false })\nexport class OtherCmp {}\n", + ); + let source = r" +import { NgModule, Component, forwardRef } from '@angular/core'; +import { LIST } from './cyc'; +@Component({ selector: 'e', template: '', standalone: false }) +export class EarlyCmp {} +export const TITLE = 'x'; +@NgModule({ declarations: [forwardRef(() => EarlyCmp), LIST] }) +export class AppModule {} +"; + let code = compile(&dir, "app.ts", source, CompilationMode::Full); + assert!( + code.contains("ɵɵsetNgModuleScope(AppModule,{declarations:()=>[EarlyCmp,LIST]})"), + "{code}" + ); + let code = compile(&dir, "app.ts", source, CompilationMode::Partial); + assert!(code.contains("type:AppModule,declarations:()=>[EarlyCmp,i1.OtherCmp]}"), "{code}"); + } + + #[test] + fn a_declaration_file_list_keeps_its_old_output() { + // ngtsc reads the tuple type (`[i1.LibModule, i1.LibDir]`); oxc + // doesn't here, so `...WRAPPED` keeps its old output instead of + // naming the list as a class. + let dir = TempDir::new().unwrap(); + write( + &dir, + "node_modules/fakelib/package.json", + r#"{ "name": "fakelib", "types": "index.d.ts" }"#, + ); + write( + &dir, + "node_modules/fakelib/index.d.ts", + r#"import * as i0 from '@angular/core'; +export declare class LibModule { + static ɵmod: i0.ɵɵNgModuleDeclaration; + static ɵinj: i0.ɵɵInjectorDeclaration; +} +export declare class LibDir { + static ɵdir: i0.ɵɵDirectiveDeclaration; +} +export declare const LIB_ALL: [typeof LibModule, typeof LibDir]; +"#, + ); + write( + &dir, + "lib.ts", + "import { LIB_ALL } from 'fakelib';\nexport const WRAPPED = [...LIB_ALL];\n", + ); + let source = r" +import { NgModule } from '@angular/core'; +import { WRAPPED } from './lib'; +@NgModule({ imports: [...WRAPPED] }) +export class M2 {} +"; + let code = compile(&dir, "app.ts", source, CompilationMode::Partial); + assert!(code.contains("type:M2}"), "{code}"); + assert!(!code.contains("LIB_ALL"), "{code}"); + } } diff --git a/crates/oxc_angular_compiler/tests/template_source_locations_test.rs b/crates/oxc_angular_compiler/tests/template_source_locations_test.rs index a6d0b6790..2b9d5fc69 100644 --- a/crates/oxc_angular_compiler/tests/template_source_locations_test.rs +++ b/crates/oxc_angular_compiler/tests/template_source_locations_test.rs @@ -354,3 +354,131 @@ fn needs_angular_19_1() { ); assert!(new.contains("attachSourceLocations"), "{new}"); } + +/// `/w/MyApp/Src/App.Component.ts`, with capitals in its directories and name. +const MY_APP: &str = "/w/MyApp/Src/App.Component.ts"; +const MY_APP_SRC: &str = "import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + template: '
Hello
{{ x }}', +}) +export class AppComponent { + x = 1; +} +"; + +/// Options for a case-insensitive file system (macOS, Windows) with `roots`. +fn insensitive(roots: &[&str]) -> TransformOptions { + TransformOptions { + root_dirs: Some(roots.iter().map(ToString::to_string).collect()), + use_case_sensitive_file_names: false, + ..options() + } +} + +/// The two calls for `MY_APP` when its path relative to the root is `path`. +fn my_app_calls(path: &str) -> [String; 2] { + [ + format!(r#"attachSourceLocations("{path}",[[0,94,4,13],[2,115,4,34]])"#), + format!( + r#"ɵsetClassDebugInfo(AppComponent,{{className:"AppComponent",filePath:"{path}",lineNumber:7}})"# + ), + ] +} + +#[test] +fn paths_are_lowercased_on_a_case_insensitive_file_system() { + // As ngtsc 22.2.3 gives them on macOS (`ts.sys.useCaseSensitiveFileNames` + // is `false`): the root and the file are compared in lowercase. + let compile = |roots: &[&str]| compile_with(MY_APP, MY_APP_SRC, &insensitive(roots), None); + assert_eq!(calls(&compile(&["/w/MyApp"])), my_app_calls("src/app.component.ts")); + assert_eq!(calls(&compile(&["/w/myapp"])), my_app_calls("src/app.component.ts")); + assert_eq!(calls(&compile(&["/w/MyApp/"])), my_app_calls("src/app.component.ts")); + assert_eq!(calls(&compile(&["/w"])), my_app_calls("myapp/src/app.component.ts")); + // The first root that holds the file wins, not the closest one. + assert_eq!(calls(&compile(&["/w/MyApp/Src", "/w/MyApp"])), my_app_calls("app.component.ts")); + assert_eq!( + calls(&compile(&["/w/MyApp", "/w/MyApp/Src"])), + my_app_calls("src/app.component.ts") + ); + // Outside every root: no source locations and no `filePath`. + assert_eq!( + calls(&compile(&["/w/Other"])), + [r#"ɵsetClassDebugInfo(AppComponent,{className:"AppComponent"})"#] + ); +} + +#[test] +fn template_url_is_lowercased_on_a_case_insensitive_file_system() { + let source = "import { NgModule, Component } from '@angular/core'; +import { FancyWidget } from './Widgets/Fancy.Widget'; + +@Component({ selector: 'app-shell', templateUrl: './Shell.View.html', standalone: false }) +export class ShellComponent {} + +@NgModule({ declarations: [ShellComponent, FancyWidget] }) +export class AppModule {} +"; + let mut resources = ResolvedResources::default(); + resources.templates.insert( + "./Shell.View.html".to_string(), + "

x

\n".to_string(), + ); + let code = compile_with( + "/w/Mod/Src/App.Module.ts", + source, + &insensitive(&["/w/Mod"]), + Some(&resources), + ); + assert_eq!( + calls(&code), + [ + r#"attachSourceLocations("src/shell.view.html",[[0,0,0,0],[1,29,0,29]])"#, + r#"ɵsetClassDebugInfo(ShellComponent,{className:"ShellComponent",filePath:"src/app.module.ts",lineNumber:5})"#, + ] + ); +} + +#[test] +fn case_is_kept_by_default() { + // Like ngtsc on a case-sensitive file system (Linux): a root in another + // case does not hold the file. + let compile = |roots: &[&str]| { + let options = TransformOptions { + root_dirs: Some(roots.iter().map(ToString::to_string).collect()), + ..options() + }; + compile_with(MY_APP, MY_APP_SRC, &options, None) + }; + assert_eq!(calls(&compile(&["/w/MyApp"])), my_app_calls("Src/App.Component.ts")); + assert_eq!( + calls(&compile(&["/w/myapp"])), + [r#"ɵsetClassDebugInfo(AppComponent,{className:"AppComponent"})"#] + ); +} + +#[test] +fn the_hmr_id_and_i18n_names_keep_their_case() { + // Both as oxc wrote them before. The Vite plugin finds a component by its + // HMR id, so the id stays the path given to the transform; the i18n name + // suffix is still made from that path too. + let options = TransformOptions { hmr: true, ..insensitive(&["/w/MyApp"]) }; + let code = compile_with(MY_APP, MY_APP_SRC, &options, None); + assert!( + code.contains(r#"constid="%2Fw%2FMyApp%2FSrc%2FApp.Component.ts%40AppComponent""#), + "{code}" + ); + assert!(code.contains("$$_W_MYAPP_SRC_APP_COMPONENT_TS_0"), "{code}"); +} + +#[test] +fn partial_mode_writes_no_paths_on_a_case_insensitive_file_system() { + let options = TransformOptions { + compilation_mode: CompilationMode::Partial, + ..insensitive(&["/w/MyApp"]) + }; + let code = compile_with(MY_APP, MY_APP_SRC, &options, None); + assert!(!code.contains("attachSourceLocations"), "{code}"); + assert!(!code.contains("setClassDebugInfo"), "{code}"); +} diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index 1ec605b65..1e4d74d7c 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -187,6 +187,9 @@ interface TransformOptions { enableTemplateSourceLocations?: boolean // needs rootDirs; full mode only // Roots that ɵsetClassDebugInfo's filePath and source locations are relative to rootDirs?: string[] + // ts.sys.useCaseSensitiveFileNames; false lowercases those paths, like ngtsc + // (default: true) + useCaseSensitiveFileNames?: boolean // Cross-file elision crossFileElision?: boolean @@ -241,6 +244,9 @@ interface PluginOptions { // Roots for ɵsetClassDebugInfo's filePath and source locations, // resolved against workspaceRoot (default: [workspaceRoot], like ngtsc) rootDirs?: string[] + // false lowercases those paths, like ngtsc on macOS and Windows + // (default: checked on the project's file system, like ts.sys) + caseSensitiveFileNames?: boolean } ``` diff --git a/napi/angular-compiler/e2e/compare/README.md b/napi/angular-compiler/e2e/compare/README.md index 1366dc86f..3836378e2 100644 --- a/napi/angular-compiler/e2e/compare/README.md +++ b/napi/angular-compiler/e2e/compare/README.md @@ -144,7 +144,8 @@ class that also has a non-Angular decorator), a fixture sets `ngtscMode: 'full'` ngtsc compiles each fixture under the project root (the compare directory), as a real build does, so that its `ɵsetClassDebugInfo` calls carry `filePath` and `lineNumber`. Oxc gets the -`rootDirs` that make its `filePath` the same project-relative path. +`rootDirs` that make its `filePath` the same project-relative path, and TypeScript's +`ts.sys.useCaseSensitiveFileNames`, so that both lowercase it on macOS and Windows. ### Known Differences (Not Bugs) diff --git a/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts b/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts index 6076f0d1b..8682e4dc1 100644 --- a/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts +++ b/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts @@ -12,6 +12,7 @@ import { transformSync as oxcTransformSync, type TransformOptions as OxcTransformOptions, } from 'oxc-transform' +import ts from 'typescript' import type { CompilerOutput, ProjectCompilationResult } from '../types.js' @@ -93,6 +94,9 @@ export function compileWithOxcFullFileRaw( // Enable class metadata for TestBed support (matches Angular's output) emitClassMetadata: true, rootDirs: options?.rootDirs, + // ngtsc lowercases paths relative to its roots on a case-insensitive file + // system (macOS, Windows); tell Oxc the same file system. + useCaseSensitiveFileNames: ts.sys.useCaseSensitiveFileNames, } try { diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 3d8de289a..08edc0665 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -1133,10 +1133,17 @@ export interface TransformOptions { * * When set, `ɵsetClassDebugInfo`'s `filePath` is relative to the first * root that contains the file (and left out when none does). When unset, - * `filePath` is the path given to the transform. Paths are never - * lowercased (ngtsc lowercases them on case-insensitive file systems). + * `filePath` is the path given to the transform. */ rootDirs?: Array + /** + * TypeScript's `ts.sys.useCaseSensitiveFileNames`. When `false` (macOS + * and Windows by default), the paths made relative to `rootDirs` are + * lowercased, like ngtsc does: `ɵsetClassDebugInfo`'s `filePath` and the + * `ɵɵattachSourceLocations` path. The HMR id keeps its case. Defaults to + * `true`. + */ + useCaseSensitiveFileNames?: boolean /** The CSS selector that identifies this component in a template. */ selector?: string /** diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index ebe0c7369..8a15c22f5 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -192,10 +192,16 @@ pub struct TransformOptions { /// /// When set, `ɵsetClassDebugInfo`'s `filePath` is relative to the first /// root that contains the file (and left out when none does). When unset, - /// `filePath` is the path given to the transform. Paths are never - /// lowercased (ngtsc lowercases them on case-insensitive file systems). + /// `filePath` is the path given to the transform. pub root_dirs: Option>, + /// TypeScript's `ts.sys.useCaseSensitiveFileNames`. When `false` (macOS + /// and Windows by default), the paths made relative to `rootDirs` are + /// lowercased, like ngtsc does: `ɵsetClassDebugInfo`'s `filePath` and the + /// `ɵɵattachSourceLocations` path. The HMR id keeps its case. Defaults to + /// `true`. + pub use_case_sensitive_file_names: Option, + // Component metadata fields for full compilation testing // These mirror Angular's @Component decorator options. /// The CSS selector that identifies this component in a template. @@ -309,6 +315,7 @@ impl From for RustTransformOptions { .enable_template_source_locations .unwrap_or(false), root_dirs: options.root_dirs, + use_case_sensitive_file_names: options.use_case_sensitive_file_names.unwrap_or(true), // Component metadata overrides selector: options.selector, standalone: options.standalone, diff --git a/napi/angular-compiler/test/source-locations.test.ts b/napi/angular-compiler/test/source-locations.test.ts index df4a3b9d8..2ea75376d 100644 --- a/napi/angular-compiler/test/source-locations.test.ts +++ b/napi/angular-compiler/test/source-locations.test.ts @@ -1,8 +1,10 @@ import { resolve } from 'node:path' +import ts from 'typescript' import { describe, expect, it } from 'vitest' import { angular, type PluginOptions } from '../vite-plugin/index.js' +import { isFileSystemCaseSensitive, swapCase } from '../vite-plugin/utils/case-sensitive.js' const SOURCE = `import { Component } from '@angular/core'; @@ -81,4 +83,47 @@ describe('@oxc-angular/vite project-relative paths', () => { ) expect(code).toContain('ɵɵattachSourceLocations("src/app.component.ts",[[0,90,2,46]]);') }) + + it('lowercases the paths with caseSensitiveFileNames: false', async () => { + // As ngtsc 22.2.3 emits it on macOS, with the root in either case. + for (const root of ['/Proj', '/proj']) { + const code = await transform( + { + enableTemplateSourceLocations: true, + workspaceRoot: abs('/Proj'), + rootDirs: [abs(root)], + caseSensitiveFileNames: false, + }, + abs('/Proj'), + abs('/Proj/Src/App.Component.ts'), + ) + expect(code).toContain('ɵɵattachSourceLocations("src/app.component.ts",[[0,90,2,46]]);') + expect(code).toContain('filePath:"src/app.component.ts",lineNumber:4') + } + }) + + it('keeps the case with caseSensitiveFileNames: true', async () => { + const code = await transform( + { workspaceRoot: abs('/Proj'), caseSensitiveFileNames: true }, + abs('/Proj'), + abs('/Proj/Src/App.Component.ts'), + ) + expect(code).toContain('filePath:"Src/App.Component.ts",lineNumber:4') + }) + + it('checks the file system like TypeScript by default', async () => { + const lowercased = !ts.sys.useCaseSensitiveFileNames + expect(isFileSystemCaseSensitive(process.cwd())).toBe(!lowercased) + const code = await transform( + { workspaceRoot: abs('/Proj') }, + abs('/Proj'), + abs('/Proj/Src/App.Component.ts'), + ) + const path = lowercased ? 'src/app.component.ts' : 'Src/App.Component.ts' + expect(code).toContain(`filePath:"${path}",lineNumber:4`) + }) + + it('swaps the case like TypeScript', () => { + expect(swapCase('/Users/a_B/x9.TS')).toBe('/uSERS/A_b/X9.ts') + }) }) diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 9d4adcccb..0b4230fef 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -37,6 +37,7 @@ import { jitPlugin } from './angular-jit-plugin.js' import { angularLinkerPlugin } from './angular-linker-plugin.js' import { ssrManifestPlugin } from './angular-ssr-manifest-plugin.js' import { installedAngularVersion } from './utils/angular-version.js' +import { isFileSystemCaseSensitive } from './utils/case-sensitive.js' import { injectDtsDeclarations } from './utils/dts.js' /** @@ -203,6 +204,15 @@ export interface PluginOptions { * so library files of a monorepo outside the app's Vite `root` still get one. */ rootDirs?: string[] + + /** + * Whether the file system tells `App.ts` from `app.ts`, like TypeScript's + * `ts.sys.useCaseSensitiveFileNames`. When `false`, the paths made relative to + * `rootDirs` (`ɵsetClassDebugInfo`'s `filePath`, the `ɵɵattachSourceLocations` + * path) are lowercased, as ngtsc does. Default: checked on the project's file + * system the way TypeScript checks it, so `false` on macOS and Windows by default. + */ + caseSensitiveFileNames?: boolean } // Match all TypeScript files - we'll filter by @Component/@Directive decorator in the handler @@ -368,6 +378,13 @@ export function angular(options: PluginOptions = {}): Plugin[] { tsconfig: options.tsconfig, } + // The `caseSensitiveFileNames` option, else checked once on first use. + let caseSensitiveFileNames = options.caseSensitiveFileNames + function useCaseSensitiveFileNames(): boolean { + caseSensitiveFileNames ??= isFileSystemCaseSensitive(workspaceRoot) + return caseSensitiveFileNames + } + // The `angularVersion` option, else the `@angular/core` version installed // for the Vite root `root` (looked up once per root). let installed: { root: string; version: AngularVersion | undefined } | undefined @@ -643,6 +660,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // working directory); `workspaceRoot` (the working directory by default) // stands in for them. rootDirs: pluginOptions.rootDirs ?? [pluginOptions.workspaceRoot], + useCaseSensitiveFileNames: useCaseSensitiveFileNames(), // Evaluate decorator metadata imported from other files the way // ngtsc's program-wide checker does (`inputs: INPUTS`, `@Input(OPTS)`, // `templateUrl: TEMPLATE_URL`). Read files land in diff --git a/napi/angular-compiler/vite-plugin/utils/case-sensitive.ts b/napi/angular-compiler/vite-plugin/utils/case-sensitive.ts new file mode 100644 index 000000000..3b3bacab3 --- /dev/null +++ b/napi/angular-compiler/vite-plugin/utils/case-sensitive.ts @@ -0,0 +1,50 @@ +import { existsSync, realpathSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +/** + * Whether the file system of the project at `root` tells `App.ts` from `app.ts`, + * the default of the `caseSensitiveFileNames` option. + * + * Checked like TypeScript's `ts.sys.useCaseSensitiveFileNames`, which ngtsc + * uses: `false` on Windows, else whether a known file is missing once the case + * of its path is swapped. TypeScript checks its own `typescript.js`, so this + * checks the project's TypeScript install (on the same volume as the project), + * or this plugin's own file when there is none. + */ +export function isFileSystemCaseSensitive(root: string): boolean { + if (process.platform === 'win32') return false + const file = findTypeScript(resolve(root)) ?? fileURLToPath(import.meta.url) + let real: string + try { + real = realpathSync(file) + } catch { + return true + } + return !existsSync(swapCase(real)) +} + +/** TypeScript's `swapCase`: each `\w` character in the other case. */ +export function swapCase(path: string): string { + return path.replace(/\w/g, (ch) => { + const up = ch.toUpperCase() + return ch === up ? ch.toLowerCase() : up + }) +} + +/** `typescript/lib/typescript.js` in the `node_modules` of `root` or above. */ +function findTypeScript(root: string): string | undefined { + if (process.versions['pnp']) { + try { + return createRequire(join(root, 'package.json')).resolve('typescript') + } catch { + return undefined + } + } + for (let dir = root; ; dir = dirname(dir)) { + const file = join(dir, 'node_modules/typescript/lib/typescript.js') + if (existsSync(file)) return file + if (dirname(dir) === dir) return undefined + } +}