From 5cfe0e02c0f0025b6d4227dd2cfa0dcb098037bb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 14:40:57 +0800 Subject: [PATCH 01/10] feat(compiler): lowercase emitted paths on case-insensitive file systems like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ngtsc lowercases paths relative to its root dirs when ts.sys.useCaseSensitiveFileNames is false (macOS, Windows), using TypeScript's toFileNameLowerCase. oxc kept the case, and dropped filePath and source locations when a root differed from the file only in case. - TransformOptions.use_case_sensitive_file_names (default true; napi useCaseSensitiveFileNames). When false, the resolved root and file are lowercased like toFileNameLowerCase, for the ɵsetClassDebugInfo filePath and the ɵɵattachSourceLocations path (inline and templateUrl). - Vite plugin: caseSensitiveFileNames option, checked by default the way ts.sys does, on the project's TypeScript install or the plugin file. - e2e compare passes ts.sys.useCaseSensitiveFileNames to oxc. - The HMR id, import paths and the i18n name suffix keep their case. Co-Authored-By: Claude Opus 5.5 --- .../src/component/source_locations.rs | 141 +++++++++++++++--- .../src/component/transform.rs | 24 ++- .../tests/template_source_locations_test.rs | 128 ++++++++++++++++ napi/angular-compiler/README.md | 6 + napi/angular-compiler/e2e/compare/README.md | 3 +- .../e2e/compare/src/compilers/oxc.ts | 4 + napi/angular-compiler/index.d.ts | 11 +- napi/angular-compiler/src/lib.rs | 11 +- .../test/source-locations.test.ts | 45 ++++++ napi/angular-compiler/vite-plugin/index.ts | 18 +++ .../vite-plugin/utils/case-sensitive.ts | 50 +++++++ 11 files changed, 413 insertions(+), 28 deletions(-) create mode 100644 napi/angular-compiler/vite-plugin/utils/case-sensitive.ts 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..3551add9d 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, } } } @@ -7075,6 +7086,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 +7378,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/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 + } +} From 129257a9de15e6c637416f3a3f162ec58e6ee6bd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 14:51:20 +0800 Subject: [PATCH 02/10] fix(compiler): read const enums through namespaces, re-exports and declaration files A constructor parameter typed by a `const enum` gets `type: undefined` in the class metadata, like ngtsc's full and partial `typeToValue`. This now also covers: - same file: a `const enum` merged with namespaces that hold only types (a merge with an instantiated namespace is TS2567 and keeps its output) - namespace imports and member paths: `k.KeyCodes`, `k.NS.Inner`, `import { NS }` + `NS.Inner`, `b.kk.KeyCodes` through `export * as kk` - default exports (`export { E as default }`, `export default E`) - every re-export form, including import-then-export and `export type { A as B } from` - `.d.ts` files (with `./x.js` -> `x.d.ts`, `.mjs` -> `.d.mts`, `.cjs` -> `.d.cts`), and package typings under node_modules (`exports` "types" condition, "types"/"typings" fields, `main`) The lookups read each file into a small shape (imports, const enums, namespaces, exports) and follow a type name the way TypeScript resolves its symbol. They use their own `export *` walk, which follows only plain `export *`: the shared walk also entered `export * as ns` sources and could report a const enum for a regular enum (result depended on map order). Enum members (`k.KeyCodes.A`) keep their reference. Anything that can't be followed keeps today's output. Every file read is a dependency (package files stay out, as before). The imported const enum paths live in a side table on `StringConsts`, so `ImportInfo` gets no new field. Co-Authored-By: Claude Opus 5.5 --- .../src/class_metadata/builders.rs | 78 +- .../src/component/cross_file_elision.rs | 131 +--- .../cross_file_elision/const_enums.rs | 404 ++++++++++ .../cross_file_elision/ng_module_scope.rs | 2 +- .../src/component/transform.rs | 79 +- .../src/directive/decorator.rs | 24 + .../tests/const_enum_ctor_param_test.rs | 724 +++++++++++++++++- 7 files changed, 1247 insertions(+), 195 deletions(-) create mode 100644 crates/oxc_angular_compiler/src/component/cross_file_elision/const_enums.rs 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..bc754812f 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..519829a21 --- /dev/null +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision/const_enums.rs @@ -0,0 +1,404 @@ +//! 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. + +use std::path::Path; +use std::rc::Rc; + +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::{CrossFileAnalyzer, Inner, MAX_EXPORT_CHAIN}; + +/// 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(Rc::clone(shape)); + } + 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; + } + let shape = + Rc::new(collect_enum_shape(&parsed.program, source_type.is_typescript_definition())); + self.enum_shapes.borrow_mut().insert(file.to_string(), Rc::clone(&shape)); + Some(shape) + } +} + +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..0fd35297c 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 @@ -439,7 +439,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 { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 3551add9d..832ac0189 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -593,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. @@ -3568,17 +3568,19 @@ 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, + }; // ngtsc's implicit signal `debugName` transform: `signal(0)` and the // other signal APIs get an ngDevMode `debugName` argument named after the @@ -5691,48 +5693,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 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/tests/const_enum_ctor_param_test.rs b/crates/oxc_angular_compiler/tests/const_enum_ctor_param_test.rs index 3e4a00ce9..2e5a766b1 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, @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: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('M') m: M) {} }"; +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:M,decorators:[{type:Inject,args:[\"M\"]}]}]", + "[{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,626 @@ 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"); - write( - &dir, - "blurred.ts", - "export const enum Blurred { A = 1 }\nnamespace Inner { export class Blurred {} }\n", - ); - write(&dir, "dts.d.ts", "export declare const enum Typed { A = 1 }\n"); let source = "import { Injectable, Inject } from '@angular/core'; import * as k from './keys'; -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('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)); - 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"); + 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 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 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'; +@Injectable() +export class Svc { + constructor(@Inject('B') b: Blurred) {} +}"; + let blurred = "export const enum Blurred { A = 1 } +namespace Inner { export class Blurred {} } +export const x = Inner; +"; + for mode in MODES { + 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:?}" + ); } } } From f19f75dddad25921e10ef812f6974ae052a01cc8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:13:42 +0800 Subject: [PATCH 03/10] fix(compiler): drop a default import kept only for a const enum type A default import named by a ctor param type was always kept, since the metadata writes `type: Foo` with its local name. When that type is an imported `const enum`, the metadata now writes `type: undefined`, so the import was left with no reader (`import KD from './typed'` even pointed at a module with no JS file). ngtsc drops it. After the const enum lookup, import elision now removes such a default import again, when only a top-level class's metadata named it. It stays when the factory names it (no `@Inject` / `@Attribute`) or a nested class's metadata does, as before. Also add a test for a local `export type { E }` of a const enum. Co-Authored-By: Claude Opus 5.5 --- .../src/component/import_elision.rs | 64 +++++++++++-- .../src/component/transform.rs | 7 ++ .../tests/const_enum_ctor_param_test.rs | 89 +++++++++++++++++++ 3 files changed, 151 insertions(+), 9 deletions(-) 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/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 832ac0189..61371bfda 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3581,6 +3581,13 @@ pub fn transform_angular_file( } _ => 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 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 2e5a766b1..965f7f118 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 @@ -976,6 +976,95 @@ export class Svc { } } + #[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`, From fbb8faec62752e48375305ad039e420a847ab7ea Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 14:56:51 +0800 Subject: [PATCH 04/10] =?UTF-8?q?fix(compiler):=20filter=20=C9=B5inj=20imp?= =?UTF-8?q?orts=20and=20exports=20like=20ngtsc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ngtsc builds an NgModule's injector imports from what each entry resolves to. Oxc now does the same where it can read every class an entry names, from this file alone or with cross-file analysis: - a standalone component is left out of `imports` when it brings in no providers (a port of `mayExportProviders`: a `providers` key on an NgModule it reaches counts, a component's own providers don't, and the providers of a `ModuleWithProviders` behind an NgModule aren't read); - an entry kept only in part is written as the classes it keeps, named as this file writes them or through a new namespace import (`i1.X`); - `...LIST` is written as `LIST` when the whole list stays, or when it holds a `ModuleWithProviders`; - `exports` lists each exported NgModule by itself; - `declare const X: readonly [typeof A, ...]` lists of declaration files are read for this. Anything unknown stays as written, and so does an entry whose kept classes are read through `forwardRef` (ngtsc's output can read a class before it exists there), or one with a class its package entry point doesn't export. `ɵɵsetNgModuleScope` keeps the lists as written. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 323 ++++-- .../oxc_angular_compiler/src/component/mod.rs | 1 + .../src/component/transform.rs | 23 +- .../src/ng_module/definition.rs | 6 +- .../src/ng_module/injector_imports.rs | 610 ++++++++--- .../oxc_angular_compiler/src/ng_module/mod.rs | 4 +- .../tests/ng_module_injector_imports_test.rs | 985 +++++++++++++++++- 7 files changed, 1728 insertions(+), 224 deletions(-) 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 0fd35297c..996ab682e 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}; @@ -34,7 +34,9 @@ use crate::ng_module::module_with_providers::{ MwpInit, MwpModule, MwpRead, MwpType, module_with_providers_init, static_method_ng_module, top_level_const_init, }; -use crate::ng_module::{InjectorDrops, InjectorEntry}; +use crate::ng_module::{ + EntryPlan, InjectorEntry, InjectorPlans, ResolvedRef, exports_plan, imports_plan, +}; /// 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,11 @@ enum Declared { Directive { selector: Option, is_component: bool, + /// What a standalone source component imports. `None` when it is + /// taken to bring in providers: a component of a declaration file + /// (ngtsc's `assumedToExportProviders`), one not known to be + /// standalone, or one whose imports can't be read. + imports: Option>, }, Pipe { name: Option, @@ -77,9 +100,15 @@ 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), } /// A directive or pipe of a scope, before it's named for the compiled file. @@ -245,7 +274,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,54 +315,84 @@ 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 { .. } => Some(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) + } + Declared::Directive { .. } => Some( + !scope + .get_or_init(|| inner.compilation_scope(&module)) + .as_ref() + .is_some_and(|scope| scope.contains(&flat.class.decl)), + ), + Declared::NgModule { .. } => Some(true), + Declared::List(_) | Declared::TypeList(_) => None, + }); + exports_plan(refs.as_deref()) + }) + .collect(); + InjectorPlans { imports, exports } } } @@ -361,7 +420,7 @@ impl CrossFileAnalyzer { file: file.clone(), namespace: namespace.map(Into::into), name: (*name).into(), - method: None, + ..RawRef::default() }; let holder = self.inner.resolve_ref(&raw, None, 0)?; let module = match method { @@ -385,11 +444,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, } } @@ -602,7 +661,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 +714,7 @@ impl Inner { file: file.clone(), namespace: namespace.map(Into::into), name: class.into(), - method: None, + ..RawRef::default() }, Some(method.to_string()), )), @@ -688,6 +747,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 +769,80 @@ 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. A class that can't be read may. + fn may_export_providers(&self, class: &ClassRef) -> bool { + // 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()]; + while let Some(class) = pending.pop() { + if !seen.insert(class.decl.clone()) { + continue; + } + let Some(declared) = self.declared(&class.decl) else { return true }; + let imports = match &*declared { + Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => continue, + Declared::Directive { imports: Some(imports), .. } + | Declared::NgModule { may_declare_providers: false, imports, .. } => imports, + Declared::Directive { imports: None, .. } + | Declared::NgModule { .. } + | Declared::List(_) + | Declared::TypeList(_) => return true, + }; + let Some(flat) = self.flatten(imports, class.owner.as_deref(), 0, false, true) else { + return true; + }; + pending.extend(flat.into_iter().map(|flat| flat.class)); + } + 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.imported_name(class, file) + } + /// 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 +1189,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 +1233,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: None, + }), + "ɵdir" => Some(Declared::Directive { + selector: string_at(1), + is_component: false, + imports: None, + }), "ɵpipe" => Some(Declared::Pipe { name: string_at(1) }), "ɵmod" => { let list = |index: usize| type_list(arguments.get(index)?, file); @@ -1093,6 +1252,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 +1289,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 +1304,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 +1337,35 @@ 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 = field("standalone").is_none_or(|value| { + matches!(value.get_inner_expression(), Expression::BooleanLiteral(b) if b.value) + }); + let imports = standalone.then(|| { + let mut refs = Vec::new(); + if let Some(value) = field("imports") { + collect_refs(value, consts, file, &mut refs, 0, false, false)?; + } + Some(refs) + }); + Some(Declared::Directive { + selector: string("selector"), + is_component: true, + imports: imports.flatten(), + }) } + "Directive" => Some(Declared::Directive { + selector: string("selector"), + is_component: false, + imports: None, + }), "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,6 +1373,7 @@ fn read_decorator<'a>( declarations: list("declarations")?, imports: list("imports")?, exports: list("exports")?, + may_declare_providers: field("providers").is_some(), }) } } @@ -1203,13 +1382,15 @@ fn read_decorator<'a>( /// 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 +1400,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 +1460,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 +1487,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/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/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 61371bfda..f83078ab4 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4804,28 +4804,39 @@ pub fn transform_angular_file( } // 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 diff --git a/crates/oxc_angular_compiler/src/ng_module/definition.rs b/crates/oxc_angular_compiler/src/ng_module/definition.rs index 3f36d6639..08249119f 100644 --- a/crates/oxc_angular_compiler/src/ng_module/definition.rs +++ b/crates/oxc_angular_compiler/src/ng_module/definition.rs @@ -353,9 +353,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..9af6f65ed 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,155 @@ -//! 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 not valid here: 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 +165,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 +195,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 +262,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 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 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 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 +326,170 @@ 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. A class it can't read may. + fn may_export_providers(&self, class: &'s Class<'s>) -> bool { + // 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]; + 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 => return true, + }; + let Some(fields) = decorator_fields(decorator) else { return true }; + if is_component { + // A component that is not standalone is an error in + // `imports`: keep it. + let standalone = field(&fields, "standalone").is_none_or(|value| { + matches!(value.get_inner_expression(), Expression::BooleanLiteral(b) if b.value) + }); + if !standalone { + return true; + } + } else if field(&fields, "providers").is_some() { + return true; + } + let Some(imports) = field(&fields, "imports") else { continue }; + let Some(refs) = self.refs(imports) else { return true }; + pending.extend(refs.iter().map(|r| r.class)); + } + 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>) -> bool, + ) -> Vec { + refs.iter() + .map(|r| ResolvedRef { + kept: local_kind(r.class, self.consts).map(|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 +506,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 => false, + LocalKind::Component(_) => graph.may_export_providers(r.class), + LocalKind::NgModule(_) => 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 => false, + LocalKind::Component(_) => !in_scope.contains(&r.class.span.start), + LocalKind::NgModule(_) => 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 +598,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/tests/ng_module_injector_imports_test.rs b/crates/oxc_angular_compiler/tests/ng_module_injector_imports_test.rs index a54585efa..86e0fb165 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,7 @@ 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(), ); } @@ -218,6 +276,178 @@ 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(), + ); +} + #[cfg(feature = "cross_file_elision")] mod cross_file { //! With cross-file analysis, imported classes, `const` lists and package @@ -227,7 +457,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 +484,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 +585,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 +607,7 @@ export class AppModule {} ( "AppModule", Some( - "StandaloneCmp,DirModule,ReexportModule,LocalModule,DirModule,ReexportModule,LocalModule", + "DirModule,ReexportModule,LocalModule,DirModule,ReexportModule,LocalModule", ), ), ], @@ -345,10 +617,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 +639,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(), ); @@ -523,6 +793,669 @@ 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 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(); From 3fb6ed1d86756750f2c78a60a2ffa82990e21a29 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:21:56 +0800 Subject: [PATCH 05/10] =?UTF-8?q?test(compiler):=20cover=20kept=20non-stan?= =?UTF-8?q?dalone=20components=20and=20unreadable=20component=20imports=20?= =?UTF-8?q?in=20=C9=B5inj?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A component that is not standalone stays in `ɵinj` imports (ngtsc fails with -992011 here), from this file, another file, a package or a list. - A component whose `imports` can't be read is still a known component: the module that declares `ACmp` keeps its component scope. Co-Authored-By: Claude Opus 5.5 --- .../tests/ng_module_injector_imports_test.rs | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) 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 86e0fb165..910dc3cef 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 @@ -229,6 +229,23 @@ import { Component, NgModule } from '@angular/core'; ); } +#[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(), + ); +} + #[test] fn what_is_not_known_to_be_a_directive_or_pipe_stays() { // A decorator not imported from `@angular/core`, an imported class (no @@ -870,6 +887,71 @@ export class AppModule {} ); } + #[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, From cfb259a9e39cf30b9d89cc2ede7b2679e4b5c06c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 14:56:35 +0800 Subject: [PATCH 06/10] fix(compiler): resolve partial NgModule lists and forward references like ngtsc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The partial ɵɵngDeclareNgModule `declarations`, `imports` and `exports` are now read like ngtsc's `resolveTypeList`: nested arrays, spreads of array literals and `const` arrays, alias consts, `{ ngModule }` objects and namespace members (`ns.X`, `ns.X.forRoot()`, `...ns.LIST`) are flattened, and each ModuleWithProviders is listed as its NgModule. Duplicates are kept. With cross-file analysis, an imported list or namespace member is read in its own file and named through `iN.X`; names this file writes stay as written. Namespace imports are numbered only after a whole top-level entry resolves, in list order. A top-level entry oxc can't read keeps its old output. The `() => [...]` wrap now follows ngtsc's position rule in both modes: the lists are wrapped when a resolved ref (top-level, nested or found through another file) is a class of this file declared after the NgModule. A forwardRef no longer wraps by itself, except when its target can't be shown to be ready: anything that is not an earlier class of this file, or an import whose file loads this one back (or any import without cross-file analysis). Full mode wraps `bootstrap` and the plain scope lists; lists it emits as written stay unwrapped, because the runtime only flattens and unwraps a list that is an array. ɵinj and the full-mode raw scope lists are unchanged. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 161 ++++- .../src/component/transform.rs | 94 +-- .../src/ng_module/decorator.rs | 19 +- .../src/ng_module/definition.rs | 26 +- .../src/ng_module/module_with_providers.rs | 665 +++++++++++------ .../tests/partial_ng_module_mwp_test.rs | 669 +++++++++++++++++- 6 files changed, 1308 insertions(+), 326 deletions(-) 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 996ab682e..203a99bb6 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 @@ -31,8 +31,8 @@ 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, @@ -397,41 +397,73 @@ impl CrossFileAnalyzer { } 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(), - ..RawRef::default() - }; - 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)?; + let named = |class: &ClassRef| { + Some(match inner.imported_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 }; + Some(Answer::Classes { classes: vec![named(&module)?], lazy: 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)?; + let lazy = forward_ref + && classes.iter().any(|class| { + inner + .imported_name(class, &file) + .is_none_or(|name| inner.loads_back(&file, &name, &class.decl)) + }); + let classes = classes.iter().map(named).collect::>()?; + Some(Answer::Classes { classes, lazy }) + } + _ if question.spread => None, + // A constant holding a `ModuleWithProviders`. + Some(Declared::List(_)) | None => ng_module(inner.mwp_const_module(&holder)?), + // A class. + Some(_) => { + let lazy = question.forward_ref + && inner + .imported_name(&holder, &file) + .is_none_or(|name| inner.loads_back(&file, &name, &holder.decl)); + Some(match question.namespace { + None => Answer::AsWritten { lazy }, + Some(_) => Answer::Classes { classes: vec![named(&holder)?], lazy }, + }) + } + } } } @@ -728,6 +760,57 @@ 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(()) + } + _ 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) { @@ -1379,6 +1462,20 @@ fn read_decorator<'a>( } } +/// 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 diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index f83078ab4..50dc0d6cd 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -4780,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, @@ -4799,7 +4799,8 @@ 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), ); } @@ -5606,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 } @@ -5643,36 +5645,34 @@ 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, - program, - class, - string_consts, - &metadata.imports, - binding, - resolve_imported, - |specifier| registry.get_or_assign(&Ident::from(allocator.alloc_str(specifier))), - ) + let current = [ + &metadata.bootstrap[..], + &metadata.declarations[..], + &metadata.imports[..], + &metadata.exports[..], + ]; + let Some(lists) = + read_module_lists(program, class, string_consts, current, binding, resolve_imported) + 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 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 08249119f..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, 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..6b0bd9a20 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,457 @@ 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, + /// Written `forwardRef(() => name)`. + pub forward_ref: bool, +} + +/// What cross-file analysis read for a [`Question`]. `lazy` is set when +/// reading the classes as the NgModule is defined may read one before it +/// is initialized: a `forwardRef` to a file that 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 { lazy: bool }, + /// The classes it gives, a `const` list flattened and each + /// `ModuleWithProviders` as its NgModule. + Classes { classes: Vec, lazy: 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`. +pub fn read_module_lists( program: &Program<'_>, class: &Class<'_>, consts: &StringConsts<'_>, - current: &[oxc_str::Ident<'a>], + current: [&[oxc_str::Ident<'_>]; 4], 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) + }) + }; + let mut walker = Walker { program, module: class, binding, resolve_imported, wrap: 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(); - - // 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 { + .collect::>>()? + .into_iter(); + lists.next(); // bootstrap + let mut next = || lists.next().unwrap_or_default(); + Some(ModuleLists { lists: [next(), next(), next()], wrap: walker.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, +} + +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 { + return vec![Entry { fallback: None, refs: self.spread(value, 0) }]; + }; + array + .elements + .iter() + .map(|element| Entry { + fallback: fallback_name(element), + refs: self.element(element, 0), + }) + .collect() + } + + 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, + } } - Some(oxc_allocator::Vec::from_iter_in( - resolved + + 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, + forward_ref: 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, forward_ref: true }; + match self.ask(&question) { + Some(Answer::AsWritten { lazy }) => self.wrap |= lazy, + 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, forward_ref: false }; + match self.ask(&question)? { + Answer::AsWritten { lazy } => { + self.wrap |= lazy; + (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, forward_ref: false }; + 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, lazy } = answer else { return None }; + self.wrap |= lazy; + 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/partial_ng_module_mwp_test.rs b/crates/oxc_angular_compiler/tests/partial_ng_module_mwp_test.rs index 8fca07562..67522590d 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,286 @@ 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}"); +} + +#[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}" + ); + assert!( + code.contains("type:SpreadModule,imports:[...MODS,AModule]}"), + "ɵinj stays raw:\n{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 +593,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 +626,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 +874,362 @@ 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_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}"); + } } From c17e66143b76779c486b1d14d9825abdc3e092b6 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:17:55 +0800 Subject: [PATCH 07/10] fix(compiler): keep the NgModule list wrap where a class may not be defined yet Two cases where the new wrap rule read a class too early, while the output before did not: - A forwardRef target imported through a barrel that loads this file back (`import { FooCmp } from './index'`). Only the class's own file was checked; now the file the name is imported from is checked too. - A forwardRef to an earlier class next to a plain entry from a file that loads this one back. The parser wrapped every list once it saw a forwardRef, so the wrap now stays then unless every entry is read and known to be defined (an unread entry keeps it too). Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 44 ++++++--- .../src/component/transform.rs | 12 ++- .../src/ng_module/module_with_providers.rs | 71 +++++++------- .../tests/partial_ng_module_mwp_test.rs | 94 +++++++++++++++++++ 4 files changed, 172 insertions(+), 49 deletions(-) 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 203a99bb6..5a53ff745 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 @@ -419,6 +419,12 @@ impl CrossFileAnalyzer { ..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.imported_name(class, &file)? { ImportedName::Local(name) => NamedClass::Local(name), @@ -428,7 +434,8 @@ impl CrossFileAnalyzer { // A `ModuleWithProviders` names an NgModule. let ng_module = |module: ClassRef| { let Declared::NgModule { .. } = &*inner.declared(&module.decl)? else { return None }; - Some(Answer::Classes { classes: vec![named(&module)?], lazy: false }) + 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)?); @@ -440,27 +447,27 @@ impl CrossFileAnalyzer { let mut classes = Vec::new(); let mut forward_ref = false; inner.flatten_list(&holder, items, &mut classes, &mut forward_ref, 0)?; - let lazy = forward_ref - && classes.iter().any(|class| { - inner - .imported_name(class, &file) - .is_none_or(|name| inner.loads_back(&file, &name, &class.decl)) - }); + let unready = classes.iter().any(unready); let classes = classes.iter().map(named).collect::>()?; - Some(Answer::Classes { classes, lazy }) + 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 lazy = question.forward_ref - && inner - .imported_name(&holder, &file) - .is_none_or(|name| inner.loads_back(&file, &name, &holder.decl)); + let unready = unready(&holder); Some(match question.namespace { - None => Answer::AsWritten { lazy }, - Some(_) => Answer::Classes { classes: vec![named(&holder)?], lazy }, + // 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, + }, }) } } @@ -561,6 +568,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 { diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 50dc0d6cd..9f843b747 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -5659,9 +5659,15 @@ fn resolve_ng_module_lists<'a>( &metadata.imports[..], &metadata.exports[..], ]; - let Some(lists) = - read_module_lists(program, class, string_consts, current, binding, resolve_imported) - else { + let Some(lists) = read_module_lists( + program, + class, + string_consts, + current, + metadata.contains_forward_decls, + binding, + resolve_imported, + ) else { return; }; metadata.contains_forward_decls = lists.wrap; 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 6b0bd9a20..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 @@ -383,20 +383,19 @@ pub struct Question<'s> { pub method: Option<&'s str>, /// Written `...name`: only a `const` array counts. pub spread: bool, - /// Written `forwardRef(() => name)`. - pub forward_ref: bool, } -/// What cross-file analysis read for a [`Question`]. `lazy` is set when -/// reading the classes as the NgModule is defined may read one before it -/// is initialized: a `forwardRef` to a file that loads this one back. +/// 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 { lazy: bool }, + AsWritten { unready: bool }, /// The classes it gives, a `const` list flattened and each - /// `ModuleWithProviders` as its NgModule. - Classes { classes: Vec, lazy: bool }, + /// `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. @@ -444,12 +443,15 @@ 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`. +/// 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<'_>]; 4], + forward_refs: bool, binding: impl Fn(&str) -> Binding, resolve_imported: Option>, ) -> Option { @@ -470,7 +472,8 @@ pub fn read_module_lists( (crate::util::is_metadata_property(property) && key == name).then_some(&property.value) }) }; - let mut walker = Walker { program, module: class, binding, resolve_imported, wrap: false }; + 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) @@ -483,7 +486,8 @@ pub fn read_module_lists( .into_iter(); lists.next(); // bootstrap let mut next = || lists.next().unwrap_or_default(); - Some(ModuleLists { lists: [next(), next(), next()], wrap: walker.wrap }) + let wrap = walker.wrap || (forward_refs && walker.unsure); + Some(ModuleLists { lists: [next(), next(), next()], wrap }) } impl ModuleLists { @@ -575,6 +579,10 @@ struct Walker<'p, 'a, 'r, B> { 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> { @@ -582,14 +590,17 @@ impl Binding> Walker<'_, '_, '_, B> { /// 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 { - return vec![Entry { fallback: None, refs: self.spread(value, 0) }]; + let refs = self.spread(value, 0); + self.unsure |= refs.is_none(); + return vec![Entry { fallback: None, refs }]; }; array .elements .iter() - .map(|element| Entry { - fallback: fallback_name(element), - refs: self.element(element, 0), + .map(|element| { + let refs = self.element(element, 0); + self.unsure |= refs.is_none(); + Entry { fallback: fallback_name(element), refs } }) .collect() } @@ -712,13 +723,8 @@ impl Binding> Walker<'_, '_, '_, B> { if !matches!((self.binding)(class), Binding::Import { .. }) { return None; } - let question = Question { - namespace: None, - name: class, - method: Some(method), - spread: false, - forward_ref: false, - }; + let question = + Question { namespace: None, name: class, method: Some(method), spread: false }; let answer = self.ask(&question)?; self.classes(answer) } @@ -750,10 +756,9 @@ impl Binding> Walker<'_, '_, '_, B> { self.wrap = true; return None; }; - let question = - Question { namespace: None, name, method: None, spread: false, forward_ref: true }; + let question = Question { namespace: None, name, method: None, spread: false }; match self.ask(&question) { - Some(Answer::AsWritten { lazy }) => self.wrap |= lazy, + Some(Answer::AsWritten { unready }) => self.wrap |= unready, Some(Answer::Classes { .. }) => { self.wrap = true; return None; @@ -766,10 +771,10 @@ impl Binding> Walker<'_, '_, '_, B> { /// 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, forward_ref: false }; + let question = Question { namespace: None, name, method: None, spread }; match self.ask(&question)? { - Answer::AsWritten { lazy } => { - self.wrap |= lazy; + Answer::AsWritten { unready } => { + self.unsure |= unready; (kept && !spread).then(|| vec![Ref::Local(name.into())]) } answer @ Answer::Classes { .. } => self.classes(answer), @@ -787,8 +792,7 @@ impl Binding> Walker<'_, '_, '_, B> { if !matches!((self.binding)(namespace), Binding::Namespace) { return None; } - let question = - Question { namespace: Some(namespace), name, method, spread, forward_ref: false }; + let question = Question { namespace: Some(namespace), name, method, spread }; let answer = self.ask(&question)?; self.classes(answer) } @@ -798,8 +802,11 @@ impl Binding> Walker<'_, '_, '_, B> { } fn classes(&mut self, answer: Answer) -> Option> { - let Answer::Classes { classes, lazy } = answer else { return None }; - self.wrap |= lazy; + 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() .map(|class| match class { 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 67522590d..51f99a56b 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 @@ -367,6 +367,39 @@ fn a_forward_ref_to_an_earlier_class_is_not_wrapped() { 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( @@ -1197,6 +1230,67 @@ export const PLAIN_MODS = [OtherModule]; 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. From 0674d46c95cce6352f63020aa4d42b5479544804 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:34:42 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(compiler):=20keep=20a=20=C9=B5inj=20e?= =?UTF-8?q?ntry=20whose=20providers=20or=20scope=20can't=20be=20read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a component's standalone flag or imports, or the module's scope, can't be read, the entry now stays as written instead of being rewritten to the classes that seemed to stay. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 102 +++++++++++------- .../src/ng_module/injector_imports.rs | 55 ++++++---- .../tests/ng_module_injector_imports_test.rs | 85 +++++++++++++++ .../tests/partial_ng_module_mwp_test.rs | 27 ++++- 4 files changed, 208 insertions(+), 61 deletions(-) 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 5a53ff745..729fa359a 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 @@ -87,11 +87,8 @@ enum Declared { Directive { selector: Option, is_component: bool, - /// What a standalone source component imports. `None` when it is - /// taken to bring in providers: a component of a declaration file - /// (ngtsc's `assumedToExportProviders`), one not known to be - /// standalone, or one whose imports can't be read. - imports: Option>, + /// What a component brings into an injector through its imports. + imports: CmpImports, }, Pipe { name: Option, @@ -111,6 +108,21 @@ enum Declared { 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. #[derive(Debug, Clone)] struct ScopeItem { @@ -365,7 +377,7 @@ impl CrossFileAnalyzer { Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => { Some(false) } - Declared::Directive { .. } => Some(inner.may_export_providers(&flat.class)), + Declared::Directive { .. } => inner.may_export_providers(&flat.class), Declared::NgModule { .. } => Some(true), Declared::List(_) | Declared::TypeList(_) => None, }); @@ -380,12 +392,11 @@ impl CrossFileAnalyzer { Declared::Directive { is_component: false, .. } | Declared::Pipe { .. } => { Some(false) } - Declared::Directive { .. } => Some( - !scope - .get_or_init(|| inner.compilation_scope(&module)) - .as_ref() - .is_some_and(|scope| scope.contains(&flat.class.decl)), - ), + // 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, }); @@ -895,32 +906,42 @@ impl Inner { /// 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. A class that can't be read may. - fn may_export_providers(&self, class: &ClassRef) -> bool { + /// 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 { return true }; + 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: Some(imports), .. } + Declared::Directive { imports: CmpImports::Read(imports), .. } | Declared::NgModule { may_declare_providers: false, imports, .. } => imports, - Declared::Directive { imports: None, .. } - | Declared::NgModule { .. } + Declared::Directive { imports: CmpImports::Providers, .. } + | Declared::NgModule { .. } => return Some(true), + Declared::Directive { imports: CmpImports::Unknown, .. } | Declared::List(_) - | Declared::TypeList(_) => return true, + | Declared::TypeList(_) => { + unknown = true; + continue; + } }; let Some(flat) = self.flatten(imports, class.owner.as_deref(), 0, false, true) else { - return true; + unknown = true; + continue; }; pending.extend(flat.into_iter().map(|flat| flat.class)); } - false + (!unknown).then_some(false) } /// How the compiled `file` writes the class `flat` names, by itself, @@ -1337,12 +1358,12 @@ fn read_declaration_type(class: &Class<'_>, file: &str) -> Option { "ɵcmp" => Some(Declared::Directive { selector: string_at(1), is_component: true, - imports: None, + imports: CmpImports::Providers, }), "ɵdir" => Some(Declared::Directive { selector: string_at(1), is_component: false, - imports: None, + imports: CmpImports::Unknown, }), "ɵpipe" => Some(Declared::Pipe { name: string_at(1) }), "ɵmod" => { @@ -1438,26 +1459,31 @@ fn read_decorator<'a>( "Component" => { // A component that is not standalone is an error in `imports`: // it is kept. - let standalone = field("standalone").is_none_or(|value| { - matches!(value.get_inner_expression(), Expression::BooleanLiteral(b) if b.value) - }); - let imports = standalone.then(|| { - let mut refs = Vec::new(); - if let Some(value) = field("imports") { - collect_refs(value, consts, file, &mut refs, 0, false, false)?; + 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(refs) - }); - Some(Declared::Directive { - selector: string("selector"), - is_component: true, - imports: imports.flatten(), - }) + 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: None, + imports: CmpImports::Unknown, }), "Pipe" => Some(Declared::Pipe { name: string("name") }), _ => { 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 9af6f65ed..bda08ea3e 100644 --- a/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs +++ b/crates/oxc_angular_compiler/src/ng_module/injector_imports.rs @@ -122,7 +122,8 @@ pub fn imports_plan(refs: Option<&[ResolvedRef]>, is_spread: bool) -> EntryPlan /// 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 not valid here: keep it, with its providers. + // 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; } @@ -439,12 +440,14 @@ impl<'s> LocalGraph<'s, '_, '_> { /// 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. A class it can't read may. - fn may_export_providers(&self, class: &'s Class<'s>) -> bool { + /// 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; @@ -453,26 +456,38 @@ impl<'s> LocalGraph<'s, '_, '_> { Some(LocalKind::DirectiveOrPipe) => continue, Some(LocalKind::Component(decorator)) => (decorator, true), Some(LocalKind::NgModule(decorator)) => (decorator, false), - None => return true, + None => { + unknown = true; + continue; + } + }; + let Some(fields) = decorator_fields(decorator) else { + unknown = true; + continue; }; - let Some(fields) = decorator_fields(decorator) else { return true }; if is_component { // A component that is not standalone is an error in // `imports`: keep it. - let standalone = field(&fields, "standalone").is_none_or(|value| { - matches!(value.get_inner_expression(), Expression::BooleanLiteral(b) if b.value) - }); - if !standalone { - return true; + 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 true; + return Some(true); } let Some(imports) = field(&fields, "imports") else { continue }; - let Some(refs) = self.refs(imports) else { return true }; + let Some(refs) = self.refs(imports) else { + unknown = true; + continue; + }; pending.extend(refs.iter().map(|r| r.class)); } - false + (!unknown).then_some(false) } /// `refs` as `ɵinj` sees them, `kept` telling what stays of a class @@ -480,11 +495,11 @@ impl<'s> LocalGraph<'s, '_, '_> { fn resolved( &self, refs: &[LocalRef<'s>], - kept: impl Fn(&LocalRef<'s>, LocalKind<'s>) -> bool, + kept: impl Fn(&LocalRef<'s>, LocalKind<'s>) -> Option, ) -> Vec { refs.iter() .map(|r| ResolvedRef { - kept: local_kind(r.class, self.consts).map(|kind| kept(r, kind)), + 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, @@ -527,9 +542,9 @@ impl<'s> InjectorEntries<'s> { .map(|entry| { let resolved = refs(entry).map(|refs| { graph.resolved(&refs, |r, kind| match kind { - LocalKind::DirectiveOrPipe => false, + LocalKind::DirectiveOrPipe => Some(false), LocalKind::Component(_) => graph.may_export_providers(r.class), - LocalKind::NgModule(_) => true, + LocalKind::NgModule(_) => Some(true), }) }); imports_plan(resolved.as_deref(), entry.is_some_and(|entry| entry.is_spread)) @@ -555,9 +570,9 @@ impl<'s> InjectorEntries<'s> { } let resolved = refs(entry).map(|refs| { graph.resolved(&refs, |r, kind| match kind { - LocalKind::DirectiveOrPipe => false, - LocalKind::Component(_) => !in_scope.contains(&r.class.span.start), - LocalKind::NgModule(_) => true, + LocalKind::DirectiveOrPipe => Some(false), + LocalKind::Component(_) => Some(!in_scope.contains(&r.class.span.start)), + LocalKind::NgModule(_) => Some(true), }) }); exports_plan(resolved.as_deref()) 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 910dc3cef..3b39e1ac6 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 @@ -465,6 +465,31 @@ const MODS2 = [StandaloneDir, LocalMod]; ); } +#[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 @@ -740,6 +765,66 @@ 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_list_through_a_local_forward_ref_stays() { // This `forwardRef` returns `PlainModule`, not what its callback gives. 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 51f99a56b..64c07addb 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 @@ -445,9 +445,30 @@ export class ExportSpreadModule {} ), "{code}" ); - assert!( - code.contains("type:SpreadModule,imports:[...MODS,AModule]}"), - "ɵinj stays raw:\n{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}" ); } From d62fa4850f1608b238862ed7ccbc576fb345098c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 15:44:24 +0800 Subject: [PATCH 09/10] perf(compiler): keep the const enum shapes of package typings for the process Each transform call has its own analyzer, so the const enum lookups parsed @angular/core's typings (about 670 KB) on every call. Package files are now kept across calls while their modification time and size stay the same (1.1 ms to 0.16 ms per file in a release build). Co-Authored-By: Claude Opus 5.5 --- .../src/component/cross_file_elision.rs | 2 +- .../cross_file_elision/const_enums.rs | 72 +++++++++++++++---- .../cross_file_elision/ng_module_scope.rs | 2 +- .../tests/const_enum_ctor_param_test.rs | 25 +++++++ 4 files changed, 87 insertions(+), 14 deletions(-) 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 bc754812f..4bec69841 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -109,7 +109,7 @@ struct Inner { scope: ng_module_scope::ScopeState, /// File path -> what the `const enum` lookups read of it (see /// [`const_enums`]). - enum_shapes: RefCell>>, + enum_shapes: RefCell>>, } /// A file parsed once per analyzer: its program, and the consts the 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 index 519829a21..82a7c504a 100644 --- 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 @@ -9,9 +9,14 @@ //! 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::rc::Rc; +use std::sync::{Arc, LazyLock, Mutex, PoisonError}; +use std::time::SystemTime; use oxc_allocator::Allocator; use oxc_ast::ast::{ @@ -22,8 +27,23 @@ 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; @@ -182,24 +202,52 @@ impl Inner { /// The [`EnumShape`] of `file`, read on first use. `None` when it can't /// be read or parsed. - fn enum_shape(&self, file: &str) -> Option> { + fn enum_shape(&self, file: &str) -> Option> { if let Some(shape) = self.enum_shapes.borrow().get(file) { - return Some(Rc::clone(shape)); - } - 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; + return Some(Arc::clone(shape)); } let shape = - Rc::new(collect_enum_shape(&parsed.program, source_type.is_typescript_definition())); - self.enum_shapes.borrow_mut().insert(file.to_string(), Rc::clone(&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). 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 729fa359a..2df675619 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 @@ -519,7 +519,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") } 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 965f7f118..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 @@ -857,6 +857,31 @@ export { Chunked, ChunkPlain, ChunkConst }; } } + #[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, + "node_modules/pkge/package.json", + r#"{ "name": "pkge", "types": "index.d.ts" }"#, + ); + 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. From 4bf939d1e2f7e9b943531d3c8c7bd3079d67ad99 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Tue, 3 Nov 2026 16:13:28 +0800 Subject: [PATCH 10/10] fix(compiler): keep NgModule lists safe where a binding or package path may break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A const list imported from a file that loads this one back keeps the () => wrap a forwardRef asked for: full compilation reads the binding as written, and it may not be initialized yet. - A declaration file's list type in a const list keeps the old output instead of being named as a class. - ɵinj and the partial lists no longer add a relative import into node_modules (a package class reached through a relative export *). ngtsc writes one, but it may not resolve and, under a dev server, loads a second copy of the package: the entry stays as written. Co-Authored-By: Claude Opus 5.5 --- .../cross_file_elision/ng_module_scope.rs | 23 +++++- .../tests/ng_module_injector_imports_test.rs | 24 ++++++ .../tests/partial_ng_module_mwp_test.rs | 76 +++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) 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 2df675619..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 @@ -437,7 +437,7 @@ impl CrossFileAnalyzer { .is_none_or(|name| inner.loads_back(&file, &name, &class.decl)) }; let named = |class: &ClassRef| { - Some(match inner.imported_name(class, &file)? { + Some(match inner.output_name(class, &file)? { ImportedName::Local(name) => NamedClass::Local(name), ImportedName::Module { specifier, name } => NamedClass::Module { specifier, name }, }) @@ -458,7 +458,10 @@ impl CrossFileAnalyzer { let mut classes = Vec::new(); let mut forward_ref = false; inner.flatten_list(&holder, items, &mut classes, &mut forward_ref, 0)?; - let unready = classes.iter().any(unready); + // 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 }) } @@ -827,6 +830,8 @@ impl Inner { 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); @@ -960,7 +965,19 @@ impl Inner { let name = self.export_name(&entry, &class.decl)?; return Some(ImportedName::Module { specifier: specifier.clone(), name }); } - self.imported_name(class, file) + 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 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 3b39e1ac6..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 @@ -825,6 +825,30 @@ import { L } from './dep'; ); } + #[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. 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 64c07addb..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 @@ -1347,4 +1347,80 @@ export class AppModule {} 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}"); + } }