From 4c4f0c45bc25a943750ddc451fcdf61e3da6a912 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Wed, 7 Oct 2026 02:17:43 +0800 Subject: [PATCH] feat(compiler): evaluate decorator metadata imported from other files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #518. Decorator metadata referencing exported consts in other files errored with "depends on 'X', which is imported from another module" even though ngtsc resolves these through its program-wide checker: // meta.ts: export const INPUTS = ['x']; import { INPUTS } from './meta'; @Directive({ inputs: INPUTS }) class A {} A new `resolve_imported_values` transform option (napi `resolveImportedValues`, on in the vite plugin and the compare harness) extends the existing cross-file analysis: `CrossFileAnalyzer` now evaluates exported `const` initializers through a resolver-aware `StringConsts`, following `export {}`, `export *`, and cross-file const chains, bounded and cycle-safe. Files read for resolution land in `TransformResult.dependencies` and are registered with Vite's watcher so metadata edits re-transform dependents in dev. Anything unresolvable — packages, `export declare`, functions, dynamic initializers — keeps the existing diagnostic unchanged. The vite plugin's transform call now passes `resolveImportedValues` plus `baseDir`/`tsconfigPath`, and consumes `result.dependencies` (previously dropped) so files read for value resolution are watched. Also: `cargo test` in CI now runs `--all-features` — feature-gated tests (cross_file_elision's whole suite) never ran before. --- .github/workflows/ci.yml | 2 +- .../src/component/cross_file_elision.rs | 937 +++++++++++++++--- .../src/component/transform.rs | 113 ++- .../src/directive/decorator.rs | 25 + .../src/directive/evaluator.rs | 138 ++- .../oxc_angular_compiler/src/directive/mod.rs | 2 + .../tests/cross_file_values_test.rs | 239 +++++ napi/angular-compiler/README.md | 2 + .../e2e/compare/src/compilers/oxc.ts | 2 + napi/angular-compiler/index.d.ts | 13 + napi/angular-compiler/package.json | 4 +- napi/angular-compiler/src/lib.rs | 14 + napi/angular-compiler/vite-plugin/index.ts | 51 +- 13 files changed, 1337 insertions(+), 205 deletions(-) create mode 100644 crates/oxc_angular_compiler/tests/cross_file_values_test.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54948c6ca..6ac54f657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: run: cargo check --all-features - name: Test - run: cargo test + run: cargo test --all-features - name: Format if: ${{ matrix.os == 'ubuntu-latest' }} 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 45333c84b..d4659ab19 100644 --- a/crates/oxc_angular_compiler/src/component/cross_file_elision.rs +++ b/crates/oxc_angular_compiler/src/component/cross_file_elision.rs @@ -1,26 +1,25 @@ -//! Cross-file type-only export detection using oxc_resolver, oxc_parser, and oxc_semantic. +//! Cross-file type-only export detection and imported-value resolution using +//! oxc_resolver and oxc_parser. //! -//! This module resolves import paths to actual files and analyzes their exports -//! to determine if they are type-only (interfaces, type aliases) or have runtime values. +//! This module resolves import paths to actual files and analyzes their exports: //! -//! ## Purpose -//! -//! The existing `ImportElisionAnalyzer` uses `oxc_semantic` to detect type-only vs value -//! references via `ReferenceFlags`. However, it cannot look across file boundaries to see -//! if an imported symbol is actually a type-only export from the source file. -//! -//! This module provides cross-file resolution to improve import elision accuracy. -//! -//! ## Scope -//! -//! This is intended for compare test purposes only. In production, bundlers like rolldown -//! handle import elision as part of their tree-shaking process. - +//! - `is_type_only_import` determines if exports are type-only (interfaces, +//! type aliases) or have runtime values, improving `ImportElisionAnalyzer`'s +//! accuracy — compare-test machinery, since bundlers handle elision in +//! production. +//! - `resolve_export_value` evaluates exported `const` initializers the way +//! ngtsc's program-wide checker does, feeding the decorator metadata +//! evaluator (`TransformOptions::resolve_imported_values`). A value that +//! can't be resolved or read statically resolves to `None`, and the caller +//! falls back to the opaque import reference — unchanged behavior. + +use std::cell::RefCell; use std::path::{Path, PathBuf}; +use std::rc::Rc; use oxc_allocator::Allocator; use oxc_ast::ast::{ - BindingPattern, Declaration, ExportDefaultDeclarationKind, Statement, + BindingPattern, Declaration, ExportDefaultDeclarationKind, Expression, Statement, TSNamespaceDeclarationBody, }; use oxc_parser::Parser; @@ -30,6 +29,8 @@ use oxc_resolver::{ use oxc_span::SourceType; use rustc_hash::{FxHashMap, FxHashSet}; +use crate::directive::ImportValueResolver; + /// Result of analyzing a file's exports #[derive(Debug, Clone)] pub struct ExportInfo { @@ -37,19 +38,74 @@ pub struct ExportInfo { pub is_type_only: bool, /// If re-export, the source module and original name pub re_export_source: Option<(String, String)>, + /// The local binding this export's value is defined by: its own name for + /// `export const X = …`, the local name for `export { Y as X }`, and + /// `"default"` for `export default ` (evaluated lazily, since + /// `evaluate_name("default")` can't). `None` for types and re-exports. + /// + /// Values are evaluated on demand in [`Inner::eval_export`] rather than + /// during `analyze_file`: eager evaluation cached `None` for exports that + /// hit a file already in `analyzing`, making the result depend on which + /// file happened to be read first. + pub(crate) value_local: Option, } -/// Cross-file analyzer for detecting type-only exports. +/// Bounds a re-export chain (`export { X } from ...`, `export *`) followed +/// while resolving a value; deeper chains stay opaque. +const MAX_EXPORT_CHAIN: u16 = 64; + +/// Cross-file analyzer for detecting type-only exports and reading exported +/// values. /// /// This analyzer resolves import paths to actual files, parses them, and /// determines if exports are type-only (interfaces, type aliases) or have -/// runtime values (classes, functions, variables). +/// runtime values (classes, functions, variables). The state lives in an +/// `Rc` so [`CrossFileResolver`]s handed to the metadata evaluator can +/// outlive the borrow of this analyzer; interior mutability lets the +/// evaluator reenter: evaluating a file's exports can require the values of +/// files it imports. pub struct CrossFileAnalyzer { + inner: Rc, +} + +struct Inner { resolver: Resolver, /// Cache of file path -> export analysis - cache: FxHashMap>, + cache: RefCell>>, /// Files currently being analyzed (for circular import detection) - analyzing: FxHashSet, + analyzing: RefCell>, + /// Source text of analyzed files, kept so `eval_export` can re-parse + /// on demand. Only populated when `values_enabled`. + sources: RefCell>, + /// Memoized per-export value evaluations: `(file, local_name)` -> value. + /// `None` means evaluated-but-opaque. + value_memo: RefCell>>, + /// Exports whose evaluation is in progress (circular `const` chains — + /// `a.A = b.B; b.B = a.A` — resolve the cycle edge to opaque, like + /// `analyzing` does for files). + evaluating_values: RefCell>, + /// Files read to resolve exported values, so build tools can watch them. + value_dependencies: RefCell>, + /// Whether exported-value resolution (`resolve_imported_values`) is on. + /// When false, `analyze_file` doesn't retain source text. + values_enabled: bool, + /// `Rc` handle to this state, for resolvers built while analyzing a file + /// (`eval_export` only sees `&self`). + this: std::rc::Weak, +} + +/// An [`ImportValueResolver`] bound to the file whose imports it resolves: +/// `module` specifiers are resolved relative to `from_file`'s directory, +/// exactly where the importing file's compiler would look. +pub(crate) struct CrossFileResolver { + inner: Rc, + from_file: PathBuf, +} + +impl ImportValueResolver for CrossFileResolver { + fn resolve(&self, module: &str, name: &str) -> Option { + self.inner.resolve_export_value(module, name, &self.from_file) + } } impl CrossFileAnalyzer { @@ -59,7 +115,9 @@ impl CrossFileAnalyzer { /// /// * `base_dir` - The base directory for module resolution /// * `tsconfig_path` - Optional path to tsconfig.json for path aliases - pub fn new(_base_dir: &Path, tsconfig_path: Option<&Path>) -> Self { + /// * `values_enabled` - Enable exported-value resolution (the + /// `resolve_imported_values` transform option) + pub fn new(_base_dir: &Path, tsconfig_path: Option<&Path>, values_enabled: bool) -> Self { let options = ResolveOptions { extensions: vec![".ts".into(), ".tsx".into(), ".js".into(), ".jsx".into()], tsconfig: tsconfig_path.map(|p| { @@ -72,12 +130,87 @@ impl CrossFileAnalyzer { }; Self { - resolver: Resolver::new(options), - cache: FxHashMap::default(), - analyzing: FxHashSet::default(), + inner: Rc::new_cyclic(|this| Inner { + resolver: Resolver::new(options), + cache: RefCell::new(FxHashMap::default()), + analyzing: RefCell::new(FxHashSet::default()), + sources: RefCell::new(FxHashMap::default()), + value_memo: RefCell::new(FxHashMap::default()), + evaluating_values: RefCell::new(FxHashSet::default()), + value_dependencies: RefCell::new(FxHashSet::default()), + values_enabled, + this: this.clone(), + }), } } + /// Check if an import is type-only by analyzing the source file. + /// + /// Returns `true` if the export is definitely type-only (interface, type alias). + /// Returns `false` if the export has a runtime value or cannot be determined. + pub fn is_type_only_import( + &self, + import_source: &str, + import_name: &str, + from_file: &Path, + ) -> bool { + self.inner.is_type_only_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 + /// the export is defined, or `None` for unresolvable/package imports. + pub fn resolve_import_source_path( + &self, + import_source: &str, + import_name: &str, + from_file: &Path, + ) -> Option { + self.inner.resolve_import_source_path(import_source, import_name, from_file) + } + + /// The static value `import_name` is exported with from `import_source`, + /// as `import_source` is written in `from_file`, following re-export + /// chains. `None` when unresolvable — the caller keeps the opaque import + /// reference. Files read are recorded in [`Self::take_value_dependencies`]. + /// + /// `CrossFileResolver` calls `Inner::resolve_export_value` directly; this + /// wrapper exists for tests. + #[cfg(test)] + pub(crate) fn resolve_export_value( + &self, + import_source: &str, + import_name: &str, + from_file: &Path, + ) -> Option { + self.inner.resolve_export_value(import_source, import_name, from_file) + } + + /// The resolver the decorator evaluator reads `from_file`'s imported + /// bindings through (see `TransformOptions::resolve_imported_values`). + pub(crate) fn value_resolver(&self, from_file: PathBuf) -> CrossFileResolver { + CrossFileResolver { inner: Rc::clone(&self.inner), from_file } + } + + /// The files read to resolve exported values since the last call, for + /// `TransformResult::dependencies` / watch wiring. + pub(crate) fn take_value_dependencies(&self) -> FxHashSet { + self.inner.take_value_dependencies() + } + + /// Clear the analysis cache. + pub fn clear_cache(&mut self) { + self.inner.clear_cache(); + } + + /// Get the number of cached files. + pub fn cache_size(&self) -> usize { + self.inner.cache_size() + } +} + +impl Inner { /// Check if an import is type-only by analyzing the source file. /// /// Returns `true` if the export is definitely type-only (interface, type alias). @@ -89,7 +222,7 @@ impl CrossFileAnalyzer { /// * `import_name` - The name being imported (e.g., "User", "Component") /// * `from_file` - The file containing the import statement pub fn is_type_only_import( - &mut self, + &self, import_source: &str, import_name: &str, from_file: &Path, @@ -112,9 +245,9 @@ impl CrossFileAnalyzer { }; // Ensure the file is analyzed (if not already cached) - if !self.cache.contains_key(&resolved) { + if !self.cache.borrow().contains_key(&resolved) { // Circular import protection - if self.analyzing.contains(&resolved) { + if self.analyzing.borrow().contains(&resolved) { return false; } self.analyze_file(&resolved); @@ -144,7 +277,7 @@ impl CrossFileAnalyzer { /// relative path from `from_file`'s directory to the source file. /// `None` if the import cannot be resolved or is a package import. pub fn resolve_import_source_path( - &mut self, + &self, import_source: &str, import_name: &str, from_file: &Path, @@ -161,8 +294,8 @@ impl CrossFileAnalyzer { // Ensure the file is analyzed let resolved_str = resolved.to_string_lossy().to_string(); - if !self.cache.contains_key(&resolved_str) { - if self.analyzing.contains(&resolved_str) { + if !self.cache.borrow().contains_key(&resolved_str) { + if self.analyzing.borrow().contains(&resolved_str) { return None; // Circular import } self.analyze_file(&resolved_str); @@ -176,14 +309,190 @@ impl CrossFileAnalyzer { self.make_relative_path(from_dir, &source_path) } + /// The static value `import_name` is exported with from `import_source`, + /// as `import_source` is written in `from_file`, following re-export + /// chains like `is_type_only_import` does. `None` when the import can't + /// be resolved, the export doesn't exist, or its value isn't statically + /// analyzable — the caller keeps the opaque import reference either way. + /// + /// Files read this way are recorded in [`Self::take_value_dependencies`]. + pub(crate) fn resolve_export_value( + &self, + import_source: &str, + import_name: &str, + from_file: &Path, + ) -> Option { + // Pre-compiled packages and files without a parent directory stay + // opaque, like `is_type_only_import`. + let parent = from_file.parent()?; + let resolved = self.resolver.resolve(parent, import_source).ok()?; + let resolved_path = resolved.full_path(); + if resolved_path.components().any(|c| c.as_os_str() == "node_modules") { + return None; + } + let resolved = resolved_path.to_string_lossy().to_string(); + self.value_dependencies.borrow_mut().insert(resolved.clone()); + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { + return None; // Circular import + } + self.analyze_file(&resolved); + } + self.export_value_at(&resolved, import_name, 0) + } + + /// The value `export_name` binds to in `file_path`, direct or through + /// re-exports. Bounded by [`MAX_EXPORT_CHAIN`] for circular chains. + fn export_value_at( + &self, + file_path: &str, + export_name: &str, + depth: u16, + ) -> Option { + if depth > MAX_EXPORT_CHAIN { + return None; + } + let export_info = { + let cache = self.cache.borrow(); + cache.get(file_path)?.get(export_name).cloned() + }; + let export_info = + export_info.or_else(|| self.find_in_star_exports(file_path, export_name))?; + + // A re-export (`export { X } from`, `export *`): follow the chain in + // the file it points at, like `check_export_is_type_only` does. + if let Some((source_module, original_name)) = export_info.re_export_source { + // `export * as ns`/`export *` entries can't name one value. + if original_name == "*" { + return None; + } + let parent = Path::new(file_path).parent()?; + let resolved = self.resolve_module_spec(parent, &source_module)?; + self.value_dependencies.borrow_mut().insert(resolved.clone()); + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { + return None; + } + self.analyze_file(&resolved); + } + return self.export_value_at(&resolved, &original_name, depth + 1); + } + + // A local export: evaluate its binding lazily. `local` is `"default"` + // for `export default `. + self.eval_export(file_path, &export_info.value_local?) + } + + /// The static value `local` is defined with in `file_path`, evaluating + /// its initializer on demand (memoized per `(file, local)`). Circular + /// `const` chains end at the [`Self::evaluating_values`] edge instead of + /// making earlier exports opaque, which is why evaluation happens here + /// and not eagerly in `analyze_file`. + fn eval_export(&self, file_path: &str, local: &str) -> Option { + let key = (file_path.to_string(), local.to_string()); + if let Some(cached) = self.value_memo.borrow().get(&key) { + return cached.clone(); + } + if !self.evaluating_values.borrow_mut().insert(key.clone()) { + return None; // const cycle back to this export + } + let value = self.eval_export_uncached(file_path, local); + self.evaluating_values.borrow_mut().remove(&key); + self.value_memo.borrow_mut().insert(key, value.clone()); + value + } + + /// [`Self::eval_export`] without the memo: re-parse the cached source and + /// evaluate the named binding. + fn eval_export_uncached( + &self, + file_path: &str, + local: &str, + ) -> Option { + // `sources` is populated by `analyze_file` when `values_enabled`; + // read the file directly as a fallback so behavior doesn't depend on + // which path discovered it first. + let source = self + .sources + .borrow() + .get(file_path) + .cloned() + .or_else(|| std::fs::read_to_string(file_path).ok())?; + + let allocator = Allocator::default(); + let source_type = SourceType::from_path(file_path).unwrap_or_default(); + let parser_ret = Parser::new(&allocator, &source, source_type).parse(); + let Some(inner) = self.this.upgrade() else { return None }; + let consts = + crate::directive::collect_string_consts(&allocator, &parser_ret.program).with_resolver( + Rc::new(CrossFileResolver { inner, from_file: Path::new(file_path).to_path_buf() }), + ); + let evaluator = crate::directive::Evaluator::new(&consts); + + if local == "default" { + return self + .find_default_expr(&parser_ret.program.body) + .and_then(|expr| evaluator.evaluate(expr).as_static()); + } + evaluator.evaluate_name(allocator.alloc_str(local)).as_static() + } + + /// The initializer expression of `export default `, searching the + /// same declaration bodies `analyze_statement` walks. + fn find_default_expr<'a>(&self, body: &'a [Statement<'a>]) -> Option<&'a Expression<'a>> { + for stmt in body { + match stmt { + Statement::ExportDefaultDeclaration(decl) => { + return decl.declaration.as_expression(); + } + Statement::TSExternalModuleDeclaration(module_decl) => { + if let Some(block) = &module_decl.body + && let Some(expr) = self.find_default_expr(&block.body) + { + return Some(expr); + } + } + Statement::TSGlobalDeclaration(global_decl) => { + if let Some(expr) = self.find_default_expr(&global_decl.body.body) { + return Some(expr); + } + } + _ => {} + } + } + None + } + + /// Resolve `spec` from `parent`'s directory into an absolute path. `spec` + /// may already be absolute — `find_in_star_exports` reports its hops that + /// way because the specifier is written relative to the star-exporting + /// file, not the file the consumer is looking at. + fn resolve_module_spec(&self, parent: &Path, spec: &str) -> Option { + if Path::new(spec).is_absolute() { + return Some(spec.to_string()); + } + self.resolver + .resolve(parent, spec) + .ok() + .map(|r| r.full_path().to_string_lossy().to_string()) + } + + /// The files [`Self::resolve_export_value`] has read since the last call. + pub(crate) fn take_value_dependencies(&self) -> FxHashSet { + std::mem::take(&mut *self.value_dependencies.borrow_mut()) + } + /// Trace an export through re-export chains to find its original source file. /// /// Returns the absolute path to the file where the export is actually defined. - fn trace_export_source(&mut self, file_path: &Path, export_name: &str) -> Option { + fn trace_export_source(&self, file_path: &Path, export_name: &str) -> Option { let file_str = file_path.to_string_lossy().to_string(); // Get export info from cache - let export_info = self.cache.get(&file_str)?.get(export_name).cloned(); + let export_info = { + let cache = self.cache.borrow(); + cache.get(&file_str)?.get(export_name).cloned() + }; // Check for star exports if we don't find the export directly let export_info = export_info.or_else(|| self.find_in_star_exports(&file_str, export_name)); @@ -199,13 +508,12 @@ impl CrossFileAnalyzer { // It's a re-export - resolve and follow the chain let parent = file_path.parent()?; - let next_file = - self.resolver.resolve(parent, &source_module).ok()?.full_path().to_path_buf(); + let next_file = PathBuf::from(self.resolve_module_spec(parent, &source_module)?); // Analyze the next file if needed let next_file_str = next_file.to_string_lossy().to_string(); - if !self.cache.contains_key(&next_file_str) { - if self.analyzing.contains(&next_file_str) { + if !self.cache.borrow().contains_key(&next_file_str) { + if self.analyzing.borrow().contains(&next_file_str) { return Some(next_file); // Circular - return current file } self.analyze_file(&next_file_str); @@ -219,11 +527,26 @@ impl CrossFileAnalyzer { /// /// When we encounter a file with star exports and don't find the export directly, /// we need to check each star export source to find where the export comes from. - fn find_in_star_exports(&mut self, file_path: &str, export_name: &str) -> Option { + /// `visited` guards circular `export *` chains (`a.ts` <-> `b.ts`) — the + /// `analyzing` set doesn't cover them because it empties once a file is + /// cached. + fn find_in_star_exports(&self, file_path: &str, export_name: &str) -> Option { + let mut visited = FxHashSet::default(); + visited.insert(file_path.to_string()); + self.find_in_star_exports_inner(file_path, export_name, &mut visited) + } + + fn find_in_star_exports_inner( + &self, + file_path: &str, + export_name: &str, + visited: &mut FxHashSet, + ) -> Option { // Collect star export sources first to avoid borrow issues // Star exports are stored with keys like "*:./module" let star_sources: Vec = { - let exports = self.cache.get(file_path)?; + let cache = self.cache.borrow(); + let exports = cache.get(file_path)?; exports .iter() .filter_map(|(key, info)| { @@ -247,31 +570,38 @@ impl CrossFileAnalyzer { Ok(r) => r.full_path().to_string_lossy().to_string(), Err(_) => continue, }; + if !visited.insert(resolved.clone()) { + continue; + } + self.value_dependencies.borrow_mut().insert(resolved.clone()); // Analyze if needed - if !self.cache.contains_key(&resolved) { - if self.analyzing.contains(&resolved) { + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { continue; } self.analyze_file(&resolved); } // Check if this file exports the name we're looking for - if let Some(exports) = self.cache.get(&resolved) { - if let Some(info) = exports.get(export_name) { - // Found it! Return with the source information - return Some(ExportInfo { - is_type_only: info.is_type_only, - re_export_source: Some((source, export_name.to_string())), - }); - } + if let Some(info) = self.cache.borrow().get(&resolved).and_then(|e| e.get(export_name)) + { + // Found it! Point at the resolved file — `source` is written + // relative to `file_path`, but consumers resolve + // `re_export_source` against the outer file's parent. + return Some(ExportInfo { + is_type_only: info.is_type_only, + re_export_source: Some((resolved, export_name.to_string())), + value_local: None, + }); } // Recursively check star exports in the resolved file - if let Some(info) = self.find_in_star_exports(&resolved, export_name) { + if let Some(info) = self.find_in_star_exports_inner(&resolved, export_name, visited) { return Some(ExportInfo { is_type_only: info.is_type_only, - re_export_source: Some((source, export_name.to_string())), + re_export_source: Some((resolved, export_name.to_string())), + value_local: None, }); } } @@ -286,7 +616,9 @@ impl CrossFileAnalyzer { let to_canonical = to_file.canonicalize().ok()?; let relative = pathdiff::diff_paths(&to_canonical, &from_canonical)?; - let mut path_str = relative.to_string_lossy().to_string(); + // The result is used as an ES module specifier — always `/`, even on + // Windows where `diff_paths` yields `\`. + let mut path_str = relative.to_string_lossy().replace('\\', "/"); // Ensure the path starts with "./" for relative imports if !path_str.starts_with('.') { @@ -304,10 +636,11 @@ impl CrossFileAnalyzer { } /// Check if an export is type-only, following re-export chains. - fn check_export_is_type_only(&mut self, file_path: &str, export_name: &str) -> bool { + fn check_export_is_type_only(&self, file_path: &str, export_name: &str) -> bool { // Get export info from cache (clone to avoid borrow issues) let export_info = { - let Some(exports) = self.cache.get(file_path) else { + let cache = self.cache.borrow(); + let Some(exports) = cache.get(file_path) else { return false; }; exports.get(export_name).cloned() @@ -328,17 +661,16 @@ impl CrossFileAnalyzer { // It's a re-export - follow the chain // Resolve the re-export source relative to the current file let current_file = Path::new(file_path); - let resolved = match current_file.parent() { - Some(parent) => match self.resolver.resolve(parent, &source_module) { - Ok(resolution) => resolution.full_path().to_string_lossy().to_string(), - Err(_) => return export_info.is_type_only, // Cannot resolve - use direct info - }, - None => return export_info.is_type_only, + let Some(resolved) = current_file + .parent() + .and_then(|parent| self.resolve_module_spec(parent, &source_module)) + else { + return export_info.is_type_only; // Cannot resolve - use direct info }; // Analyze the re-export source if not cached - if !self.cache.contains_key(&resolved) { - if self.analyzing.contains(&resolved) { + if !self.cache.borrow().contains_key(&resolved) { + if self.analyzing.borrow().contains(&resolved) { return export_info.is_type_only; // Circular - use direct info } self.analyze_file(&resolved); @@ -346,7 +678,8 @@ impl CrossFileAnalyzer { // Check the re-export source recursively let source_is_type_only = { - let Some(source_exports) = self.cache.get(&resolved) else { + let cache = self.cache.borrow(); + let Some(source_exports) = cache.get(&resolved) else { return export_info.is_type_only; }; source_exports.get(&original_name).map(|info| info.is_type_only) @@ -356,17 +689,21 @@ impl CrossFileAnalyzer { } /// Analyze a file and cache its export information. - fn analyze_file(&mut self, file_path: &str) { - self.analyzing.insert(file_path.to_string()); + fn analyze_file(&self, file_path: &str) { + self.analyzing.borrow_mut().insert(file_path.to_string()); let source = match std::fs::read_to_string(file_path) { Ok(s) => s, Err(_) => { - self.analyzing.remove(file_path); + self.analyzing.borrow_mut().remove(file_path); return; } }; + if self.values_enabled { + self.sources.borrow_mut().insert(file_path.to_string(), source.clone()); + } + let allocator = Allocator::default(); let source_type = SourceType::from_path(file_path).unwrap_or_default(); let parser_ret = Parser::new(&allocator, &source, source_type).parse(); @@ -377,16 +714,12 @@ impl CrossFileAnalyzer { self.analyze_statement(stmt, &mut exports); } - self.cache.insert(file_path.to_string(), exports); - self.analyzing.remove(file_path); + self.cache.borrow_mut().insert(file_path.to_string(), exports); + self.analyzing.borrow_mut().remove(file_path); } /// Analyze a statement for export information. - fn analyze_statement<'a>( - &self, - stmt: &Statement<'a>, - exports: &mut FxHashMap, - ) { + fn analyze_statement(&self, stmt: &Statement, exports: &mut FxHashMap) { match stmt { // export class/function/const/interface/type Foo { ... } Statement::ExportDeclaration(decl) => { @@ -399,23 +732,27 @@ impl CrossFileAnalyzer { let name = spec.exported.name().to_string(); exports.insert( name, - ExportInfo { is_type_only: true, re_export_source: None }, + ExportInfo { + is_type_only: true, + re_export_source: None, + value_local: None, + }, ); } return; } - // Export specifiers without source: export { X, Y } - // These re-export local bindings - we need to check if the local - // binding is type-only. For now, mark as not type-only (conservative). + // Export specifiers without source: export { X, Y }. The + // export binds the LOCAL name — `export { Y as X }` reads `Y` + // (`export { A }` of an imported `A` resolves through the + // evaluator's resolver at `eval_export` time). for spec in &decl.specifiers { let name = spec.exported.name().to_string(); + let is_type_only = spec.export_kind.is_type(); + let value_local = (!is_type_only).then(|| spec.local.name().to_string()); exports.insert( name, - ExportInfo { - is_type_only: spec.export_kind.is_type(), - re_export_source: None, - }, + ExportInfo { is_type_only, re_export_source: None, value_local }, ); } } @@ -429,7 +766,11 @@ impl CrossFileAnalyzer { let name = spec.exported.name().to_string(); exports.insert( name, - ExportInfo { is_type_only: true, re_export_source: None }, + ExportInfo { + is_type_only: true, + re_export_source: None, + value_local: None, + }, ); } return; @@ -443,6 +784,7 @@ impl CrossFileAnalyzer { ExportInfo { is_type_only: spec.export_kind.is_type(), re_export_source: Some((decl.source.value.to_string(), local_name)), + value_local: None, }, ); } @@ -452,9 +794,15 @@ impl CrossFileAnalyzer { &decl.declaration, ExportDefaultDeclarationKind::TSInterfaceDeclaration(_) ); + // `export default ` is evaluated lazily; the `"default"` + // local tells `eval_export` to look for the declaration. exports.insert( "default".to_string(), - ExportInfo { is_type_only, re_export_source: None }, + ExportInfo { + is_type_only, + re_export_source: None, + value_local: (!is_type_only).then(|| "default".to_string()), + }, ); } Statement::ExportAllDeclaration(decl) => { @@ -466,6 +814,7 @@ impl CrossFileAnalyzer { ExportInfo { is_type_only: false, // Namespace re-export - assume value re_export_source: Some((source_module, "*".to_string())), + value_local: None, }, ); } else { @@ -477,6 +826,7 @@ impl CrossFileAnalyzer { ExportInfo { is_type_only: false, re_export_source: Some((source_module, "*".to_string())), + value_local: None, }, ); } @@ -504,9 +854,9 @@ impl CrossFileAnalyzer { } /// Walk a namespace body, recursing through nested `namespace A.B` forms. - fn analyze_namespace_body<'a>( + fn analyze_namespace_body( &self, - body: &TSNamespaceDeclarationBody<'a>, + body: &TSNamespaceDeclarationBody, exports: &mut FxHashMap, ) { match body { @@ -522,64 +872,46 @@ impl CrossFileAnalyzer { } /// Analyze a declaration and add export information. - fn analyze_declaration<'a>( - &self, - decl: &Declaration<'a>, - exports: &mut FxHashMap, - ) { + fn analyze_declaration(&self, decl: &Declaration, exports: &mut FxHashMap) { + // A runtime export binds the declared name; its value is evaluated + // lazily by `eval_export`. Type declarations have no value local. + let value_info = |name: &str, is_type_only: bool| ExportInfo { + is_type_only, + re_export_source: None, + value_local: (!is_type_only).then(|| name.to_string()), + }; match decl { Declaration::TSInterfaceDeclaration(d) => { - exports.insert( - d.id.name.to_string(), - ExportInfo { is_type_only: true, re_export_source: None }, - ); + exports.insert(d.id.name.to_string(), value_info(&d.id.name, true)); } Declaration::TSTypeAliasDeclaration(d) => { - exports.insert( - d.id.name.to_string(), - ExportInfo { is_type_only: true, re_export_source: None }, - ); + exports.insert(d.id.name.to_string(), value_info(&d.id.name, true)); } Declaration::ClassDeclaration(d) => { if let Some(id) = &d.id { - exports.insert( - id.name.to_string(), - ExportInfo { is_type_only: false, re_export_source: None }, - ); + exports.insert(id.name.to_string(), value_info(&id.name, false)); } } Declaration::FunctionDeclaration(d) => { if let Some(id) = &d.id { - exports.insert( - id.name.to_string(), - ExportInfo { is_type_only: false, re_export_source: None }, - ); + exports.insert(id.name.to_string(), value_info(&id.name, false)); } } Declaration::VariableDeclaration(d) => { // Extract names from variable declarations for declarator in &d.declarations { if let BindingPattern::BindingIdentifier(id) = &declarator.id { - exports.insert( - id.name.to_string(), - ExportInfo { is_type_only: false, re_export_source: None }, - ); + exports.insert(id.name.to_string(), value_info(&id.name, false)); } } } Declaration::TSEnumDeclaration(d) => { // Enums have runtime value (unless const enum with isolatedModules) - exports.insert( - d.id.name.to_string(), - ExportInfo { is_type_only: false, re_export_source: None }, - ); + exports.insert(d.id.name.to_string(), value_info(&d.id.name, false)); } Declaration::TSNamespaceDeclaration(d) => { // Namespaces can have runtime value; `id` is a BindingIdentifier - exports.insert( - d.id.name.to_string(), - ExportInfo { is_type_only: false, re_export_source: None }, - ); + exports.insert(d.id.name.to_string(), value_info(&d.id.name, false)); } Declaration::TSExternalModuleDeclaration(_) | Declaration::TSImportEqualsDeclaration(_) @@ -590,13 +922,15 @@ impl CrossFileAnalyzer { } /// Clear the analysis cache. - pub fn clear_cache(&mut self) { - self.cache.clear(); + pub fn clear_cache(&self) { + self.cache.borrow_mut().clear(); + self.sources.borrow_mut().clear(); + self.value_memo.borrow_mut().clear(); } /// Get the number of cached files. pub fn cache_size(&self) -> usize { - self.cache.len() + self.cache.borrow().len() } } @@ -622,7 +956,7 @@ mod tests { create_test_file(dir.path(), "types.ts", "export interface User { name: string; }"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.is_type_only_import("./types", "User", &main_file)); } @@ -632,7 +966,7 @@ mod tests { create_test_file(dir.path(), "types.ts", "export type UserId = string;"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.is_type_only_import("./types", "UserId", &main_file)); } @@ -642,7 +976,7 @@ mod tests { create_test_file(dir.path(), "service.ts", "export class AuthService {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./service", "AuthService", &main_file)); } @@ -652,7 +986,7 @@ mod tests { create_test_file(dir.path(), "utils.ts", "export function helper() {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./utils", "helper", &main_file)); } @@ -662,7 +996,7 @@ mod tests { create_test_file(dir.path(), "constants.ts", "export const TOKEN = 'token';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./constants", "TOKEN", &main_file)); } @@ -672,7 +1006,7 @@ mod tests { create_test_file(dir.path(), "enums.ts", "export enum Status { Active, Inactive }"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./enums", "Status", &main_file)); } @@ -683,7 +1017,7 @@ mod tests { create_test_file(dir.path(), "index.ts", "export { Foo } from './types';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.is_type_only_import("./index", "Foo", &main_file)); } @@ -694,7 +1028,7 @@ mod tests { create_test_file(dir.path(), "index.ts", "export { MyService } from './service';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./index", "MyService", &main_file)); } @@ -705,7 +1039,7 @@ mod tests { create_test_file(dir.path(), "foo.ts", "export class Foo {}"); // Even though Foo is a class let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // export type { X } is always type-only, regardless of what X is assert!(analyzer.is_type_only_import("./types", "Foo", &main_file)); } @@ -725,7 +1059,7 @@ export const USER_TOKEN = 'token'; ); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.is_type_only_import("./mixed", "User", &main_file)); assert!(!analyzer.is_type_only_import("./mixed", "UserService", &main_file)); assert!(analyzer.is_type_only_import("./mixed", "UserId", &main_file)); @@ -737,7 +1071,7 @@ export const USER_TOKEN = 'token'; let dir = TempDir::new().unwrap(); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // Package imports should return false (conservative - assume value) assert!(!analyzer.is_type_only_import("@angular/core", "Component", &main_file)); assert!(!analyzer.is_type_only_import("rxjs", "Observable", &main_file)); @@ -748,7 +1082,7 @@ export const USER_TOKEN = 'token'; let dir = TempDir::new().unwrap(); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // Non-existent file should return false (conservative) assert!(!analyzer.is_type_only_import("./nonexistent", "Foo", &main_file)); } @@ -759,7 +1093,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "types.ts", "export interface User {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // First call - should analyze assert!(analyzer.is_type_only_import("./types", "User", &main_file)); @@ -776,7 +1110,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "types.ts", "export default interface User {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.is_type_only_import("./types", "default", &main_file)); } @@ -786,7 +1120,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "service.ts", "export default class MyService {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(!analyzer.is_type_only_import("./service", "default", &main_file)); } @@ -798,7 +1132,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "component.ts", "export class MyComponent {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./component", "MyComponent", &main_file); assert_eq!(resolved, Some("./component".to_string())); @@ -811,7 +1145,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "index.ts", "export { MyComponent } from './component';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./index", "MyComponent", &main_file); assert_eq!(resolved, Some("./component".to_string())); } @@ -828,7 +1162,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "index.ts", "export { DeepComponent } from './deep';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./index", "DeepComponent", &main_file); assert_eq!(resolved, Some("./deep/component".to_string())); } @@ -840,7 +1174,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "index.ts", "export * from './service';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./index", "MyService", &main_file); assert_eq!(resolved, Some("./service".to_string())); } @@ -853,7 +1187,7 @@ export const USER_TOKEN = 'token'; create_test_file(dir.path(), "index.ts", "export * from './deep';"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./index", "helper", &main_file); assert_eq!(resolved, Some("./deep/util".to_string())); } @@ -873,7 +1207,7 @@ export { doSomething } from './utils'; ); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // Star export let resolved_config = analyzer.resolve_import_source_path("./index", "Config", &main_file); @@ -889,7 +1223,7 @@ export { doSomething } from './utils'; let dir = TempDir::new().unwrap(); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!( analyzer.resolve_import_source_path("@angular/core", "Component", &main_file).is_none() ); @@ -901,7 +1235,7 @@ export { doSomething } from './utils'; let dir = TempDir::new().unwrap(); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!(analyzer.resolve_import_source_path("./nonexistent", "Foo", &main_file).is_none()); } @@ -911,7 +1245,7 @@ export { doSomething } from './utils'; create_test_file(dir.path(), "module.ts", "export class Exists {}"); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); assert!( analyzer.resolve_import_source_path("./module", "DoesNotExist", &main_file).is_none() ); @@ -930,7 +1264,7 @@ export { doSomething } from './utils'; create_test_file(dir.path(), "app/main.ts", "// placeholder"); let main_file = dir.path().join("app/main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("../shared/index", "SharedComponent", &main_file); assert_eq!(resolved, Some("../shared/component".to_string())); @@ -947,7 +1281,7 @@ export { doSomething } from './utils'; ); let main_file = dir.path().join("main.ts"); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); let resolved = analyzer.resolve_import_source_path("./index", "RenamedExport", &main_file); assert_eq!(resolved, Some("./original".to_string())); } @@ -984,7 +1318,7 @@ export { doSomething } from './utils'; // Create parent dir so the path is valid std::fs::create_dir_all(main_file.parent().unwrap()).unwrap(); - let mut analyzer = CrossFileAnalyzer::new(dir.path(), None); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); // WidgetControlService is an interface — should be type-only assert!( @@ -1002,4 +1336,311 @@ export { doSomething } from './utils'; "WIDGET_CONTROL const should NOT be type-only" ); } + + // ---- resolve_export_value (imported decorator metadata values) ---- + + use crate::directive::StaticValue; + + fn resolve( + analyzer: &CrossFileAnalyzer, + from: &Path, + module: &str, + name: &str, + ) -> Option { + analyzer.resolve_export_value(module, name, from) + } + + #[test] + fn test_value_simple_consts() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "other.ts", + "export const NAME = 'a';\n\ + export const N = 42;\n\ + export const B = true;\n\ + export const INPUTS = ['a', 'b'];\n\ + export const OPTS = { alias: 'b', required: true };\n\ + export const E = 1 + 2;", + ); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "NAME"), + Some(StaticValue::String(s)) if s == "a" + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "N"), + Some(StaticValue::Number(n)) if n == 42.0 + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "B"), + Some(StaticValue::Bool(true)) + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "INPUTS"), + Some(StaticValue::Array(items)) if matches!(&items[..], [StaticValue::String(a), StaticValue::String(b)] if a == "a" && b == "b") + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "OPTS"), + Some(StaticValue::Object(props)) if props.iter().any(|(k, _)| k == "alias") + )); + // Computed same-file expressions evaluate too. + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "E"), + Some(StaticValue::Number(n)) if n == 3.0 + )); + } + + #[test] + fn test_value_const_chain_across_files() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "a.ts", "export const A = ['a'];"); + create_test_file( + dir.path(), + "b.ts", + "import { A } from './a';\nexport const B = [...A, 'b'];", + ); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./b", "B"), + Some(StaticValue::Array(items)) if items.len() == 2 + )); + } + + #[test] + fn test_value_re_export_chain() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "deep.ts", "export const X = 'deep';"); + create_test_file(dir.path(), "mid.ts", "export { X as Y } from './deep';"); + create_test_file(dir.path(), "index.ts", "export { Y } from './mid';"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./index", "Y"), + Some(StaticValue::String(s)) if s == "deep" + )); + } + + #[test] + fn test_value_star_export() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "impl.ts", "export const SEL = 'sel';"); + create_test_file(dir.path(), "index.ts", "export * from './impl';"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./index", "SEL"), + Some(StaticValue::String(s)) if s == "sel" + )); + } + + #[test] + fn test_value_export_default_expression() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "d.ts", "export default 'val';"); + create_test_file(dir.path(), "named.ts", "const X = 'x';\nexport default X;"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./d", "default"), + Some(StaticValue::String(s)) if s == "val" + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./named", "default"), + Some(StaticValue::String(s)) if s == "x" + )); + } + + #[test] + fn test_value_local_export_of_imported_binding() { + // `import { A } from './a'; export { A };` — a "local" export that is + // really a re-export; ngtsc's checker follows it. + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "a.ts", "export const A = 'a';"); + create_test_file(dir.path(), "b.ts", "import { A } from './a';\nexport { A };"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./b", "A"), + Some(StaticValue::String(s)) if s == "a" + )); + } + + #[test] + fn test_value_enum_member() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "enums.ts", + "export enum K { A = 'a', B = 'b' }\nexport const E = K;", + ); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + // The enum evaluates to its member object; `E.A`-shaped access + // resolves at the member step in the caller's evaluator. + assert!(matches!( + resolve(&analyzer, &main_file, "./enums", "E"), + Some(StaticValue::Object(props)) if props.iter().any(|(k, _)| k == "A") + )); + } + + #[test] + fn test_value_unresolvable_falls_back() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "other.ts", + "export declare const DECLARED: string;\n\ + export const FN = () => 1;\n\ + export function f() {}", + ); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + // Missing file, missing export, declare-only, function values, and + // package imports all stay opaque (None → the import-reference error). + assert!(resolve(&analyzer, &main_file, "./missing", "X").is_none()); + assert!(resolve(&analyzer, &main_file, "./other", "NOPE").is_none()); + assert!(resolve(&analyzer, &main_file, "./other", "DECLARED").is_none()); + assert!(resolve(&analyzer, &main_file, "./other", "FN").is_none()); + assert!(resolve(&analyzer, &main_file, "./other", "f").is_none()); + assert!(resolve(&analyzer, &main_file, "rxjs", "of").is_none()); + } + + #[test] + fn test_value_circular_files_terminate() { + // `export const` cycles between files must not hang or panic; each + // unresolvable link becomes opaque. + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "a.ts", + "import { B } from './b';\nexport const A = B;\nexport const SELF = 'a';", + ); + create_test_file(dir.path(), "b.ts", "import { A } from './a';\nexport const B = A;"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + // The circular pair stays opaque; a directly-static export still resolves. + assert!(matches!( + resolve(&analyzer, &main_file, "./a", "SELF"), + Some(StaticValue::String(s)) if s == "a" + )); + } + + #[test] + fn test_value_cycle_through_analyzing_file_resolves() { + // `a.C` reads `b.B`, which reads `a.A`. Evaluating eagerly while `a` + // was in `analyzing` cached `b.B` as opaque and made the result depend + // on which file was discovered first; ngtsc resolves both. + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "a.ts", + "import { B } from './b';\nexport const A = 'a';\nexport const C = B;", + ); + create_test_file(dir.path(), "b.ts", "import { A } from './a';\nexport const B = A;"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "./a", "C"), + Some(StaticValue::String(s)) if s == "a" + )); + assert!(matches!( + resolve(&analyzer, &main_file, "./b", "B"), + Some(StaticValue::String(s)) if s == "a" + )); + } + + #[test] + fn test_value_elision_only_analyzer_resolves_on_demand() { + // `resolve_export_value` works without `values_enabled`: `sources` is + // empty and the file is re-read for evaluation. + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "other.ts", "export const V = 'v';"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, false); + + assert!(matches!( + resolve(&analyzer, &main_file, "./other", "V"), + Some(StaticValue::String(s)) if s == "v" + )); + } + + #[test] + fn test_value_dependencies_recorded() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "other.ts", "export const V = 1;"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + resolve(&analyzer, &main_file, "./other", "V"); + let deps = analyzer.take_value_dependencies(); + assert_eq!(deps.len(), 1); + assert!(deps.iter().next().unwrap().ends_with("other.ts")); + // Taken deps don't repeat. + assert!(analyzer.take_value_dependencies().is_empty()); + } + + #[test] + fn test_value_circular_star_exports_terminate() { + // `export *` cycles (a -> b -> a) recursed forever in + // `find_in_star_exports` because the `analyzing` guard empties once + // a file is cached. The lookup must terminate with `None`. + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "a.ts", "export * from './b';\nexport const A = 'a';"); + create_test_file(dir.path(), "b.ts", "export * from './a';"); + let main_file = dir.path().join("main.ts"); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + // X doesn't exist anywhere: follow a -> b -> a once, then stop. + assert!(resolve(&analyzer, &main_file, "./a", "X").is_none()); + // A direct export in the cycle still resolves. + assert!(matches!( + resolve(&analyzer, &main_file, "./a", "A"), + Some(StaticValue::String(s)) if s == "a" + )); + // Same for the type-only path, which shares find_in_star_exports. + assert!(!analyzer.is_type_only_import("./a", "X", &main_file)); + } + + #[test] + fn test_value_star_export_nested_dirs() { + // The star specifier is written relative to the barrel's directory; + // `resolve_export_value` must resolve it there, not relative to the + // importing file. + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "feat/lib/impl.ts", "export const SEL = 'nested';"); + create_test_file(dir.path(), "feat/index.ts", "export * from './lib/impl';"); + let main_file = dir.path().join("app/main.ts"); + std::fs::create_dir_all(main_file.parent().unwrap()).unwrap(); + let analyzer = CrossFileAnalyzer::new(dir.path(), None, true); + + assert!(matches!( + resolve(&analyzer, &main_file, "../feat", "SEL"), + Some(StaticValue::String(s)) if s == "nested" + )); + // Every file read along the hop chain lands in dependencies. Paths + // are OS-native (`\` on Windows) — normalize before suffix checks. + let deps = analyzer.take_value_dependencies(); + let normalized: Vec = deps.iter().map(|d| d.replace('\\', "/")).collect(); + assert!( + normalized.iter().any(|d| d.ends_with("feat/index.ts")), + "{normalized:?} should contain the barrel" + ); + assert!( + normalized.iter().any(|d| d.ends_with("feat/lib/impl.ts")), + "{normalized:?} should contain the star-exported file" + ); + } } diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 0d36e7fcb..098a2da7e 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -152,6 +152,22 @@ pub struct TransformOptions { #[cfg(feature = "cross_file_elision")] pub cross_file_elision: bool, + /// Evaluate decorator metadata values imported from other files + /// (`@Directive({inputs: INPUTS})`, `@Input(OPTS)`, ...), the way ngtsc's + /// program-wide checker does. + /// + /// When true, imported bindings in evaluated positions resolve to their + /// exporting file's value — following re-exports, `export *` chains and + /// `const` chains across files. Values that can't be resolved or read + /// statically (packages, `.d.ts` declarations, non-literals) stay opaque + /// and keep the "cannot evaluate values from other files" diagnostic. + /// + /// Files read this way are reported in `TransformResult::dependencies`. + /// Uses `base_dir`/`tsconfig_path` for module resolution, like + /// `cross_file_elision`. + #[cfg(feature = "cross_file_elision")] + pub resolve_imported_values: bool, + /// Base directory for module resolution. /// /// Used when `cross_file_elision` is enabled to resolve relative imports. @@ -250,6 +266,8 @@ impl Default for TransformOptions { #[cfg(feature = "cross_file_elision")] cross_file_elision: false, #[cfg(feature = "cross_file_elision")] + resolve_imported_values: false, + #[cfg(feature = "cross_file_elision")] base_dir: None, #[cfg(feature = "cross_file_elision")] tsconfig_path: None, @@ -2922,47 +2940,62 @@ pub fn transform_angular_file( // by tracking the next index and passing it to each component. let mut shared_pool_index: u32 = 0; + // One analyzer serves both cross-file features: the type-only scan for + // import elision and, when resolve_imported_values is on, the resolver the + // decorator evaluator reads imported bindings through. It's declared + // before `string_consts` so the resolver borrowed by it drops first. + #[cfg(feature = "cross_file_elision")] + let cross_file_analyzer: Option = + if options.cross_file_elision || options.resolve_imported_values { + let file_path = std::path::Path::new(path); + let base_dir = options.base_dir.as_deref().or_else(|| file_path.parent()); + base_dir.map(|base| { + CrossFileAnalyzer::new( + base, + options.tsconfig_path.as_deref(), + options.resolve_imported_values, + ) + }) + } else { + None + }; + // When cross_file_elision is enabled, collect type-only information for each import // by checking if the exported symbol is an interface or type alias. This is separate // from barrel resolution to avoid changing namespace import paths. #[cfg(feature = "cross_file_elision")] - let cross_file_type_only: FxHashMap = if options.cross_file_elision { + let cross_file_type_only: FxHashMap = if options.cross_file_elision + && let Some(analyzer) = &cross_file_analyzer + { let file_path = std::path::Path::new(path); - let base_dir = options.base_dir.as_deref().or_else(|| file_path.parent()); - - if let Some(base) = base_dir { - let mut analyzer = CrossFileAnalyzer::new(base, options.tsconfig_path.as_deref()); - let mut type_only: FxHashMap = FxHashMap::default(); + let mut type_only: FxHashMap = FxHashMap::default(); - for stmt in &parser_ret.program.body { - let Statement::ImportDeclaration(import_decl) = stmt else { - continue; - }; + for stmt in &parser_ret.program.body { + let Statement::ImportDeclaration(import_decl) = stmt else { + continue; + }; - let source = import_decl.source.value.as_str(); - let Some(specifiers) = &import_decl.specifiers else { - continue; - }; + let source = import_decl.source.value.as_str(); + let Some(specifiers) = &import_decl.specifiers else { + continue; + }; - for specifier in specifiers { - if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { - let local_name = spec.local.name.as_str(); - let imported_name = spec.imported.name().as_str(); + for specifier in specifiers { + if let ImportDeclarationSpecifier::ImportSpecifier(spec) = specifier { + let local_name = spec.local.name.as_str(); + let imported_name = spec.imported.name().as_str(); - // Check if this import is type-only using the original import path. - // Resolves the file and checks if the exported symbol is an interface - // or type alias. Unresolvable imports return false (conservative). - if analyzer.is_type_only_import(source, imported_name, file_path) { - type_only.insert(local_name.to_string(), true); - } + // Check if this import is type-only using the original import path. + // Resolves the file and checks if the exported symbol is an interface + // or type alias. Unresolvable imports return false (conservative). + if analyzer.is_type_only_import(source, imported_name, file_path) { + type_only.insert(local_name.to_string(), true); } } } - - type_only - } else { - FxHashMap::default() } + + type_only } else { FxHashMap::default() }; @@ -2977,8 +3010,18 @@ pub fn transform_angular_file( // Collect file-scope string consts so decorator metadata can resolve identifier // references (e.g. `host: { [ATTR_NAME]: '' }`) the same way the official - // Angular compiler does. - let string_consts = collect_string_consts(allocator, &parser_ret.program); + // Angular compiler does. `resolve_imported_values` teaches the evaluator + // to read imported bindings the way ngtsc's checker does. + #[allow(unused_mut)] + let mut string_consts = collect_string_consts(allocator, &parser_ret.program); + #[cfg(feature = "cross_file_elision")] + if options.resolve_imported_values + && let Some(analyzer) = &cross_file_analyzer + { + string_consts = string_consts.with_resolver(std::rc::Rc::new( + analyzer.value_resolver(std::path::PathBuf::from(path)), + )); + } #[cfg(feature = "cross_file_elision")] let mut import_map = @@ -4315,6 +4358,16 @@ pub fn transform_angular_file( result.code = apply_edits(source, edits); } + // Files read to resolve imported metadata values become watch + // dependencies, like template/style URLs — only when the option that + // asked for those reads is on. + #[cfg(feature = "cross_file_elision")] + if options.resolve_imported_values + && let Some(analyzer) = &cross_file_analyzer + { + result.dependencies.extend(analyzer.take_value_dependencies()); + } + result } diff --git a/crates/oxc_angular_compiler/src/directive/decorator.rs b/crates/oxc_angular_compiler/src/directive/decorator.rs index e96a73f35..1f2fb7b32 100644 --- a/crates/oxc_angular_compiler/src/directive/decorator.rs +++ b/crates/oxc_angular_compiler/src/directive/decorator.rs @@ -497,6 +497,11 @@ pub struct StringConsts<'a> { program: Option<&'a Program<'a>>, /// Built on first use: most files have no decorator metadata to evaluate. scope: std::cell::OnceCell>, + /// Reads the value an imported binding has in its exporting file, like + /// ngtsc's program-wide checker (`None`: imports stay opaque references). + /// `Rc`-shared so it can borrow the analyzer that produced it. + #[cfg(feature = "cross_file_elision")] + resolver: Option>, } impl<'a> StringConsts<'a> { @@ -505,6 +510,22 @@ impl<'a> StringConsts<'a> { self.strings.get(name) } + /// The import value resolver attached to these consts, if any. + #[cfg(feature = "cross_file_elision")] + pub(crate) fn resolver(&self) -> Option<&dyn super::evaluator::ImportValueResolver> { + self.resolver.as_deref() + } + + /// Attach an import value resolver (see [`Self::resolver`]). + #[cfg(feature = "cross_file_elision")] + pub(crate) fn with_resolver( + mut self, + resolver: std::rc::Rc, + ) -> Self { + self.resolver = Some(resolver); + self + } + /// `program`'s declarations without its folded strings: enough to tell /// where a name is imported from (see /// [`super::angular_param_decorator`]). @@ -513,6 +534,8 @@ impl<'a> StringConsts<'a> { strings: HashMap::default(), program: Some(program), scope: std::cell::OnceCell::new(), + #[cfg(feature = "cross_file_elision")] + resolver: None, } } @@ -564,6 +587,8 @@ pub fn collect_string_consts<'a>( strings: HashMap::default(), program: Some(program), scope: std::cell::OnceCell::new(), + #[cfg(feature = "cross_file_elision")] + resolver: None, }; loop { let before = map.strings.len(); diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 368a03ddc..0727cd814 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -1181,8 +1181,9 @@ pub(crate) enum Value<'a> { String(String), Array(Vec>), Object(Vec>), - /// `import * as ns`. - Module, + /// `import * as ns`, with the module specifier it was imported from so + /// member access can ask the resolver about `ns`'s exports. + Module(&'a str), Reference { name: String, kind: RefKind<'a>, @@ -1201,6 +1202,92 @@ pub(crate) enum Value<'a> { Function(FnDef<'a>), } +/// A value read from another file, in the owned form an +/// [`ImportValueResolver`] hands the evaluator. ngtsc resolves imported +/// bindings through the whole program's checker; a resolver answers the same +/// question for the bindings it can see (`cross_file_elision`'s +/// `CrossFileAnalyzer`), so an exported `const` evaluates the way it does +/// here. +#[derive(Clone, Debug)] +// Only `to_static` (feature-gated) builds these; without the feature the enum +// is just the resolver's return type. +#[cfg_attr(not(feature = "cross_file_elision"), allow(dead_code))] +pub(crate) enum StaticValue { + Null, + Undefined, + Bool(bool), + Number(f64), + String(String), + Array(Vec), + /// Object literal properties. Later duplicates win, like [`Value::prop`]. + Object(Vec<(String, StaticValue)>), +} + +impl StaticValue { + /// `value` as a [`StaticValue`], or `None` when any part of it can't leave + /// its file (references, functions, builtins, dynamic or partial members). + #[cfg(feature = "cross_file_elision")] + pub(crate) fn to_static(value: &Value<'_>) -> Option { + match value { + Value::Null => Some(Self::Null), + Value::Undefined => Some(Self::Undefined), + Value::Bool(b) => Some(Self::Bool(*b)), + Value::Number(n) => Some(Self::Number(*n)), + Value::String(s) => Some(Self::String(s.clone())), + Value::Array(items) => { + items.iter().map(Self::to_static).collect::>().map(Self::Array) + } + Value::Object(props) => props + .iter() + .map(|p| Self::to_static(&p.value).map(|v| (p.key.clone(), v))) + .collect::>() + .map(Self::Object), + // An enum member carries its resolved value. + Value::Enum { value, .. } => Self::to_static(value), + _ => None, + } + } + + /// The [`Value`] an identifier bound to this import resolves to. Built + /// from owned data, so it fits any arena lifetime. + #[cfg(feature = "cross_file_elision")] + pub(crate) fn to_value(&self) -> Value<'static> { + match self { + Self::Null => Value::Null, + Self::Undefined => Value::Undefined, + Self::Bool(b) => Value::Bool(*b), + Self::Number(n) => Value::Number(*n), + Self::String(s) => Value::String(s.clone()), + Self::Array(items) => Value::Array(items.iter().map(Self::to_value).collect()), + Self::Object(props) => Value::Object( + props + .iter() + .map(|(key, value)| Prop { + key: key.clone(), + value: value.to_value(), + expr: None, + origin: None, + }) + .collect(), + ), + } + } +} + +/// Answers the value an imported binding has in the file it's exported from. +/// +/// The evaluator consults it at the two places a [`Value`] is built from an +/// `import`: the identifier itself, and a member of `import * as ns`. `None` +/// falls back to the opaque import reference, so every diagnostic and emit a +/// missing or unresolvable value produced before is unchanged. +#[cfg(feature = "cross_file_elision")] +pub(crate) trait ImportValueResolver { + /// The exported value of `name` in `module`, as `module` is written in + /// the importing file (`import { name } from 'module'`), or `None` when + /// it can't be resolved or read statically. + fn resolve(&self, module: &str, name: &str) -> Option; +} + /// ngtsc's `KnownFn`s (partial_evaluator/src/builtin.ts), bound to their receiver. #[derive(Clone)] pub(crate) enum Builtin<'a> { @@ -1237,6 +1324,13 @@ impl<'a> Value<'a> { } } + /// This value in the owned form that crosses a file boundary + /// ([`StaticValue`]), or `None` when any part of it can't leave its file. + #[cfg(feature = "cross_file_elision")] + pub(crate) fn as_static(&self) -> Option { + StaticValue::to_static(self) + } + /// Whether this is an imported binding (or a value computed from one), which /// can't be evaluated without reading another file. pub(crate) fn is_import(&self) -> bool { @@ -1300,7 +1394,7 @@ impl<'a> Value<'a> { .collect::>() .join(", ") ), - Value::Module => "(module)".into(), + Value::Module(_) => "(module)".into(), _ if self.is_dynamic() => "(not statically analyzable)".into(), Value::Reference { name, .. } | Value::Enum { name, .. } => name.clone(), Value::Builtin(_) => "Function".into(), @@ -1524,6 +1618,13 @@ impl<'s, 'a> Evaluator<'s, 'a> { self.eval(expr, 0, &Frame::default()) } + /// The value a top-level `name` resolves to where the metadata is read. + /// Used by cross-file value resolution to read an export by its name. + #[cfg(feature = "cross_file_elision")] + pub(crate) fn evaluate_name(&self, name: &'a str) -> Value<'a> { + self.identifier(name, 0, &Frame::default()) + } + /// `expr` (evaluated in `frame`) as it can be written where the metadata is /// compiled. Outside a called function that's `expr` itself. In one, a /// parameter stands for the argument it was passed (ngtsc emits the @@ -1956,13 +2057,24 @@ impl<'s, 'a> Evaluator<'s, 'a> { if let Some(import) = scope.imports.get(name) { return match import.imported { Some(imported) => { + // `imported` is the name the other file exports it under + // ("default" for a default import); an import resolver + // reads its value across the file boundary like ngtsc's + // checker, and everything unresolvable keeps the opaque + // reference and its diagnostics. + #[cfg(feature = "cross_file_elision")] + if let Some(resolver) = self.consts.resolver() + && let Some(value) = resolver.resolve(import.module, imported) + { + return value.to_value(); + } let declared = if imported == "default" { name } else { imported }; Value::Reference { name: declared.into(), kind: RefKind::Import { namespace_member: false, local: Some(name) }, } } - None => Value::Module, + None => Value::Module(import.module), }; } match name { @@ -2499,10 +2611,20 @@ impl<'s, 'a> Evaluator<'s, 'a> { Value::String(s) if matches!(key, Key::Str("concat")) => { Value::Builtin(Builtin::StringConcat(s)) } - Value::Module => Value::Reference { - name: key_str(), - kind: RefKind::Import { namespace_member: true, local: None }, - }, + Value::Module(_module) => { + // `ns.x`: the resolver answers with `x`'s value in `module`, + // like the identifier path does for `import { x }`. + #[cfg(feature = "cross_file_elision")] + if let Some(resolver) = self.consts.resolver() + && let Some(value) = resolver.resolve(_module, &key_str()) + { + return value.to_value(); + } + Value::Reference { + name: key_str(), + kind: RefKind::Import { namespace_member: true, local: None }, + } + } Value::Reference { kind: RefKind::Class(class), .. } => { self.static_member(class, &key_str(), depth) } diff --git a/crates/oxc_angular_compiler/src/directive/mod.rs b/crates/oxc_angular_compiler/src/directive/mod.rs index b2439851c..3471422c6 100644 --- a/crates/oxc_angular_compiler/src/directive/mod.rs +++ b/crates/oxc_angular_compiler/src/directive/mod.rs @@ -42,6 +42,8 @@ pub(crate) use decorator::{merge_by_class_property, parse_decorator_io}; pub use definition::{DirectiveDefinitions, generate_directive_definitions}; pub(crate) use dts_type::quote as ts_string_literal; pub use evaluator::input_transform_types; +#[cfg(feature = "cross_file_elision")] +pub(crate) use evaluator::{Evaluator, ImportValueResolver, StaticValue}; pub use metadata::{ QueryPredicate, R3DirectiveMetadata, R3DirectiveMetadataBuilder, R3HostDirectiveMetadata, R3HostMetadata, R3InputMetadata, R3QueryMetadata, diff --git a/crates/oxc_angular_compiler/tests/cross_file_values_test.rs b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs new file mode 100644 index 000000000..8a950159a --- /dev/null +++ b/crates/oxc_angular_compiler/tests/cross_file_values_test.rs @@ -0,0 +1,239 @@ +//! End-to-end tests for `TransformOptions::resolve_imported_values` +//! (#518): decorator metadata that references values exported from other +//! files (`inputs: INPUTS`, `@Input(OPTS)`) must evaluate like ngtsc's +//! program-wide checker instead of erroring with "imported from another +//! module". Unresolvable imports keep that diagnostic. + +#![cfg(feature = "cross_file_elision")] + +use oxc_allocator::Allocator; +use oxc_angular_compiler::{TransformOptions, TransformResult, transform_angular_file}; +use tempfile::TempDir; + +fn create_test_file(dir: &std::path::Path, name: &str, content: &str) -> std::path::PathBuf { + let path = dir.join(name); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, content).unwrap(); + path +} + +/// The whitespace-free `ɵɵdefineDirective` call for `class_name`. +fn define_call(code: &str, class_name: &str) -> String { + let compact: String = code.chars().filter(|c| !c.is_whitespace()).collect(); + let start = compact.find(&format!("({{type:{class_name},")).expect("define call"); + let end = compact[start..].find("});").map_or(compact.len(), |i| start + i); + compact[start..end].to_string() +} + +fn transform(dir: &TempDir, source: &str, options: &TransformOptions) -> TransformResult { + let path = create_test_file(dir.path(), "app/test.ts", source); + let allocator = Allocator::default(); + transform_angular_file(&allocator, path.to_str().unwrap(), source, Some(options), None) +} + +fn resolve_options() -> TransformOptions { + TransformOptions { resolve_imported_values: true, ..Default::default() } +} + +fn error_messages(result: &TransformResult) -> String { + result.diagnostics.iter().map(|d| d.to_string()).collect::>().join("\n") +} + +#[test] +fn imported_inputs_array_evaluates() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const INPUTS = ['x', 'y: why'];"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from './meta'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "A").contains(r#"inputs:{x:"x",y:[0,"why","y"]}"#), + "{}", + define_call(&result.code, "A") + ); +} + +#[test] +fn input_member_options_object_evaluates() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "app/meta.ts", + "export const OPTS = { alias: 'r', required: true };", + ); + let result = transform( + &dir, + r#"import { Directive, Input } from '@angular/core'; +import { OPTS } from './meta'; +@Directive({ selector: 'a' }) +export class A { @Input(OPTS) prop?: string; } +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "A").contains(r#"inputs:{prop:[0,"r","prop"]}"#), + "{}", + define_call(&result.code, "A") + ); +} + +#[test] +fn namespace_import_member_evaluates() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const X = { alias: 'x-x' };"); + let result = transform( + &dir, + r#"import { Directive, Input } from '@angular/core'; +import * as meta from './meta'; +@Directive({ selector: 'a' }) +export class A { @Input(meta.X) prop?: string; } +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "A").contains(r#"inputs:{prop:[0,"x-x","prop"]}"#), + "{}", + define_call(&result.code, "A") + ); +} + +#[test] +fn imported_values_follow_re_export_chain() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/impl.ts", "export const INPUTS = ['x'];"); + create_test_file(dir.path(), "app/inner.ts", "export { INPUTS as X } from './impl';"); + create_test_file(dir.path(), "app/outer.ts", "export * from './inner';"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { X } from './outer'; +@Directive({ selector: 'a', inputs: X }) +export class A {} +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + assert!( + define_call(&result.code, "A").contains(r#"inputs:{x:"x"}"#), + "{}", + define_call(&result.code, "A") + ); +} + +#[test] +fn read_files_are_reported_as_dependencies() { + let dir = TempDir::new().unwrap(); + let meta = create_test_file(dir.path(), "app/meta.ts", "export const INPUTS = ['x'];"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from './meta'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &resolve_options(), + ); + assert!(!result.has_errors(), "{}", error_messages(&result)); + // Compare canonicalized paths: the resolver's `full_path()` can differ + // from `tempdir`'s form (macOS /var -> /private/var, Windows `\\?\` + // verbatim prefixes and 8.3 short names like RUNNER~1). + let meta = std::fs::canonicalize(&meta).unwrap(); + assert!( + result.dependencies.iter().any(|d| std::fs::canonicalize(d).is_ok_and(|c| c == meta)), + "dependencies {:?} should contain {}", + result.dependencies, + meta.display() + ); +} + +#[test] +fn unresolvable_import_keeps_diagnostic() { + let dir = TempDir::new().unwrap(); + // `require()` isn't statically evaluable; the import stays opaque. + create_test_file(dir.path(), "app/meta.ts", "export const INPUTS = require('./other');"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from './meta'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &resolve_options(), + ); + assert!(result.has_errors()); + let errors = error_messages(&result); + assert!(errors.contains("'INPUTS', which is imported from another module"), "{errors}"); +} + +#[test] +fn missing_export_keeps_diagnostic() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const OTHER = ['x'];"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from './meta'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &resolve_options(), + ); + assert!(result.has_errors()); + assert!( + error_messages(&result).contains("'INPUTS', which is imported from another module"), + "{}", + error_messages(&result) + ); +} + +#[test] +fn feature_off_keeps_diagnostic() { + let dir = TempDir::new().unwrap(); + create_test_file(dir.path(), "app/meta.ts", "export const INPUTS = ['x'];"); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from './meta'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &TransformOptions::default(), + ); + assert!(result.has_errors()); + assert!( + error_messages(&result).contains("'INPUTS', which is imported from another module"), + "{}", + error_messages(&result) + ); +} + +#[test] +fn package_import_is_not_resolved() { + let dir = TempDir::new().unwrap(); + let result = transform( + &dir, + r#"import { Directive } from '@angular/core'; +import { INPUTS } from 'some-pkg'; +@Directive({ selector: 'a', inputs: INPUTS }) +export class A {} +"#, + &resolve_options(), + ); + assert!(result.has_errors()); + assert!( + error_messages(&result).contains("'INPUTS', which is imported from another module"), + "{}", + error_messages(&result) + ); +} diff --git a/napi/angular-compiler/README.md b/napi/angular-compiler/README.md index b17e37315..0e14b81ce 100644 --- a/napi/angular-compiler/README.md +++ b/napi/angular-compiler/README.md @@ -183,6 +183,8 @@ interface TransformOptions { // Cross-file elision crossFileElision?: boolean + // Evaluate decorator metadata imported from other files (as ngtsc does) + resolveImportedValues?: boolean baseDir?: string tsconfigPath?: string } diff --git a/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts b/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts index 08a88d304..2556a0544 100644 --- a/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts +++ b/napi/angular-compiler/e2e/compare/src/compilers/oxc.ts @@ -78,6 +78,8 @@ export function compileWithOxcFullFileRaw( angularVersion: OXC_ANGULAR_VERSION, // Enable cross-file analysis for barrel export tracing crossFileElision: true, + // Evaluate decorator metadata imported from other files, as ngtsc does + resolveImportedValues: true, baseDir: path.dirname(filePath), // Pass tsconfig for resolving monorepo path aliases (e.g., @cu/*) tsconfigPath: options?.tsconfigPath, diff --git a/napi/angular-compiler/index.d.ts b/napi/angular-compiler/index.d.ts index 8ed370ee6..bd196d071 100644 --- a/napi/angular-compiler/index.d.ts +++ b/napi/angular-compiler/index.d.ts @@ -850,6 +850,19 @@ export interface TransformOptions { * handle import elision during tree-shaking. */ crossFileElision?: boolean + /** + * Evaluate decorator metadata values imported from other files + * (`@Directive({inputs: INPUTS})`, `@Input(OPTS)`, ...), the way ngtsc's + * program-wide checker does. + * + * When true, exported `const` initializers resolve through re-exports and + * `export *` chains. Unresolvable values keep the + * "cannot evaluate values from other files" diagnostic, and read files + * appear in `TransformResult.dependencies`. + * + * Uses `baseDir`/`tsconfigPath` for module resolution. + */ + resolveImportedValues?: boolean /** * Base directory for module resolution. * diff --git a/napi/angular-compiler/package.json b/napi/angular-compiler/package.json index 371eb5d70..4e0740f9d 100644 --- a/napi/angular-compiler/package.json +++ b/napi/angular-compiler/package.json @@ -54,14 +54,14 @@ }, "scripts": { "artifacts": "napi artifacts", - "build-dev": "oxnode build.ts --esm --platform --features allocator", + "build-dev": "oxnode build.ts --esm --platform --features allocator,cross_file_elision", "build-test": "oxnode build.ts --esm --platform --features allocator,cross_file_elision && pnpm run build:ts", "build": "pnpm run build:native && pnpm run build:ts", "build:native": "pnpm run build-dev --release", "build:ts": "tsc -p tsconfig.json", "test": "vitest run --dir ./test", "test:e2e": "playwright test --config=e2e/playwright.config.ts", - "preversion": "oxnode build.ts --platform --esm --features allocator && git add index.js index.d.ts", + "preversion": "oxnode build.ts --platform --esm --features allocator,cross_file_elision && git add index.js index.d.ts", "prepublishOnly": "napi pre-publish -t npm" }, "dependencies": { diff --git a/napi/angular-compiler/src/lib.rs b/napi/angular-compiler/src/lib.rs index fa9404b1d..c75f19fae 100644 --- a/napi/angular-compiler/src/lib.rs +++ b/napi/angular-compiler/src/lib.rs @@ -182,6 +182,18 @@ pub struct TransformOptions { /// handle import elision during tree-shaking. pub cross_file_elision: Option, + /// Evaluate decorator metadata values imported from other files + /// (`@Directive({inputs: INPUTS})`, `@Input(OPTS)`, ...), the way ngtsc's + /// program-wide checker does. + /// + /// When true, exported `const` initializers resolve through re-exports and + /// `export *` chains. Unresolvable values keep the + /// "cannot evaluate values from other files" diagnostic, and read files + /// appear in `TransformResult.dependencies`. + /// + /// Uses `baseDir`/`tsconfigPath` for module resolution. + pub resolve_imported_values: Option, + /// Base directory for module resolution. /// /// Used when `cross_file_elision` is enabled to resolve relative imports. @@ -253,6 +265,8 @@ impl From for RustTransformOptions { #[cfg(feature = "cross_file_elision")] cross_file_elision: options.cross_file_elision.unwrap_or(false), #[cfg(feature = "cross_file_elision")] + resolve_imported_values: options.resolve_imported_values.unwrap_or(false), + #[cfg(feature = "cross_file_elision")] base_dir: options.base_dir.map(std::path::PathBuf::from), #[cfg(feature = "cross_file_elision")] tsconfig_path: options.tsconfig_path.map(std::path::PathBuf::from), diff --git a/napi/angular-compiler/vite-plugin/index.ts b/napi/angular-compiler/vite-plugin/index.ts index 656e0f1c4..1ad31b88f 100644 --- a/napi/angular-compiler/vite-plugin/index.ts +++ b/napi/angular-compiler/vite-plugin/index.ts @@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { ServerResponse } from 'node:http' -import { dirname, resolve } from 'node:path' +import { dirname, isAbsolute, resolve } from 'node:path' import { createDebug } from 'obug' import type { Plugin, ResolvedConfig, ViteDevServer, Connect, ModuleNode } from 'vite' @@ -254,6 +254,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { angularVersion: options.angularVersion, emitClassMetadata: options.emitClassMetadata ?? true, compilationMode: options.compilationMode ?? 'full', + tsconfig: options.tsconfig, } let resolvedConfig: ResolvedConfig @@ -924,6 +925,37 @@ export function angular(options: PluginOptions = {}): Plugin[] { // bundles (see @angular/build application-code-bundle.js). const isSSR = !!options?.ssr + // Transform with Rust compiler + const transformOptions: TransformOptions = { + sourcemap: pluginOptions.sourceMap, + jit: pluginOptions.jit, + hmr: pluginOptions.liveReload && watchMode && !isSSR, + angularVersion: pluginOptions.angularVersion, + minifyComponentStyles: getMinifyComponentStyles(this as any), + emitClassMetadata: pluginOptions.emitClassMetadata, + compilationMode: pluginOptions.compilationMode, + // Evaluate decorator metadata imported from other files the way + // ngtsc's program-wide checker does (`inputs: INPUTS`, `@Input(OPTS)`). + // Read files land in `result.dependencies`, tracked below. + resolveImportedValues: true, + baseDir: dirname(actualId), + tsconfigPath: pluginOptions.tsconfig, + } + + const result = await transformAngularFile(code, actualId, transformOptions, resources) + + // Resource deps (templates/styles) plus files the compiler read to + // resolve imported decorator-metadata values (`result.dependencies`). + // Those `import` statements are often elided from the emitted JS — + // the module graph would not cover `meta.ts`, so without this, edits + // to metadata files leave stale compiled output. `result.dependencies` + // also carries raw `templateUrl`/`styleUrl` strings — they can be + // relative, so absolutize them against the component's directory. + const componentDir = dirname(actualId) + const allDeps = dependencies + .concat(result.dependencies) + .map((dep) => (isAbsolute(dep) ? dep : resolve(componentDir, dep))) + // Track dependencies for resource cache invalidation and HMR. // `handleHotUpdate` below dispatches based on `resourceToComponent` // membership. Preprocessor deps can resolve outside the root @@ -942,7 +974,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { // referenced different resources (e.g., templateUrl was renamed), // drop the old entries so `handleHotUpdate` stops treating them // as component-owned. - const newDeps = new Set(dependencies.map(normalizePath)) + const newDeps = new Set(allDeps.map(normalizePath)) for (const [resource, owner] of resourceToComponent) { if (owner === actualId && !newDeps.has(resource)) { resourceToComponent.delete(resource) @@ -961,7 +993,7 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - for (const dep of dependencies) { + for (const dep of allDeps) { const normalizedDep = normalizePath(dep) // Track reverse mapping for HMR: resource → component resourceToComponent.set(normalizedDep, actualId) @@ -1002,19 +1034,6 @@ export function angular(options: PluginOptions = {}): Plugin[] { } } - // Transform with Rust compiler - const transformOptions: TransformOptions = { - sourcemap: pluginOptions.sourceMap, - jit: pluginOptions.jit, - hmr: pluginOptions.liveReload && watchMode && !isSSR, - angularVersion: pluginOptions.angularVersion, - minifyComponentStyles: getMinifyComponentStyles(this as any), - emitClassMetadata: pluginOptions.emitClassMetadata, - compilationMode: pluginOptions.compilationMode, - } - - const result = await transformAngularFile(code, actualId, transformOptions, resources) - // Report errors and warnings for (const error of result.errors) { this.error(error.message)