diff --git a/fixtures/tsconfig/cases/references-cycle/a/src/index.ts b/fixtures/tsconfig/cases/references-cycle/a/src/index.ts new file mode 100644 index 00000000..bd571ae0 --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/a/src/index.ts @@ -0,0 +1 @@ +export const from = "a"; diff --git a/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json b/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json new file mode 100644 index 00000000..2cab053f --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/a/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@a/*": ["src/*"] + } + }, + "references": [{ "path": "../b" }] +} diff --git a/fixtures/tsconfig/cases/references-cycle/b/src/index.ts b/fixtures/tsconfig/cases/references-cycle/b/src/index.ts new file mode 100644 index 00000000..fc37097d --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/b/src/index.ts @@ -0,0 +1 @@ +export const from = "b"; diff --git a/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json b/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json new file mode 100644 index 00000000..26cc84a1 --- /dev/null +++ b/fixtures/tsconfig/cases/references-cycle/b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@b/*": ["src/*"] + } + }, + "references": [{ "path": "../a" }] +} diff --git a/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts b/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts new file mode 100644 index 00000000..aec3f835 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/app/aliased/index.ts @@ -0,0 +1 @@ +export const from = "app"; diff --git a/fixtures/tsconfig/cases/references-extends/app/tsconfig.json b/fixtures/tsconfig/cases/references-extends/app/tsconfig.json new file mode 100644 index 00000000..5f3ab415 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [{ "path": "../project_b" }] +} diff --git a/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts b/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts new file mode 100644 index 00000000..9bd27886 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/project_b/src/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_b via extends"; diff --git a/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json b/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json new file mode 100644 index 00000000..15e488c9 --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/project_b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "baseUrl": "./src" + } +} diff --git a/fixtures/tsconfig/cases/references-extends/tsconfig.base.json b/fixtures/tsconfig/cases/references-extends/tsconfig.base.json new file mode 100644 index 00000000..7426947c --- /dev/null +++ b/fixtures/tsconfig/cases/references-extends/tsconfig.base.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "paths": { + "@/*": ["./aliased/*"] + } + } +} diff --git a/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts new file mode 100644 index 00000000..aec3f835 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/app/aliased/index.ts @@ -0,0 +1 @@ +export const from = "app"; diff --git a/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json new file mode 100644 index 00000000..5f3ab415 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/app/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [{ "path": "../project_b" }] +} diff --git a/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts new file mode 100644 index 00000000..2f74543f --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_b/src/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_b"; diff --git a/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json new file mode 100644 index 00000000..4a964b85 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./aliased/*"] + } + }, + "references": [{ "path": "../project_c" }] +} diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts b/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts new file mode 100644 index 00000000..9aed6fb9 --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_c/aliased/index.ts @@ -0,0 +1 @@ +export const from = "project_c"; diff --git a/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json new file mode 100644 index 00000000..85b91efa --- /dev/null +++ b/fixtures/tsconfig/cases/references-transitive/project_c/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": "./", + "paths": { + "@/*": ["./aliased/*"] + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 707a1f27..f5296379 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1475,35 +1475,9 @@ impl ResolverGeneric { tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig"); // Extend tsconfig - if let Some(extends) = &tsconfig.extends { - let extended_tsconfig_paths = match extends { - ExtendsField::Single(s) => { - vec![ - self - .get_extended_tsconfig_path(&directory, &tsconfig, s) - .await?, - ] - } - ExtendsField::Multiple(specifiers) => { - try_join_all( - specifiers - .iter() - .map(|s| self.get_extended_tsconfig_path(&directory, &tsconfig, s)), - ) - .await? - } - }; - for extended_tsconfig_path in extended_tsconfig_paths { - let extended_tsconfig = self - .load_tsconfig( - /* root */ false, - &extended_tsconfig_path, - &TsconfigReferences::Disabled, - ) - .await?; - tsconfig.extend_tsconfig(&extended_tsconfig); - } - } + self + .merge_tsconfig_extends(&mut tsconfig, &directory) + .await?; // Load project references match references { @@ -1521,31 +1495,8 @@ impl ResolverGeneric { .collect(); } } - if !tsconfig.references.is_empty() { - let directory = tsconfig.directory().to_path_buf(); - for reference in &mut tsconfig.references { - let reference_tsconfig_path = directory.normalize_with(&reference.path); - let reference_tsconfig = self - .cache - .tsconfig( - /* root */ true, - &reference_tsconfig_path, - |reference_tsconfig| async { - if reference_tsconfig.path == tsconfig.path { - return Err(ResolveError::TsconfigSelfReference( - reference_tsconfig.path.clone(), - )); - } - Ok(reference_tsconfig) - }, - ) - .await?; - tsconfig - .file_dependencies - .extend(reference_tsconfig.file_dependencies.iter().cloned()); - reference.tsconfig.replace(reference_tsconfig); - } - } + let mut visited = FxHashSet::default(); + self.load_references(&mut tsconfig, &mut visited).await?; Ok(tsconfig) }) @@ -1554,6 +1505,113 @@ impl ResolverGeneric { Box::pin(fut) } + // Walks `tsconfig.references` and loads each referenced tsconfig, recursing + // into nested references so that transitive `paths` (e.g. A → B → C) are + // honored — matching `tsc`'s "nearest tsconfig wins" behavior and + // `enhanced-resolve`'s recursive `references: "auto"` walk. + // + // `visited` carries the canonical paths of tsconfigs already being loaded + // along the current chain. When a reference points back into the chain, + // the cycle edge is cut: the reference is still attached with its own + // `paths` honored, but its nested references are not walked. + fn load_references<'a>( + &'a self, + tsconfig: &'a mut TsConfig, + visited: &'a mut FxHashSet, + ) -> BoxFuture<'a, Result<(), ResolveError>> { + Box::pin(async move { + if tsconfig.references.is_empty() { + return Ok(()); + } + let directory = tsconfig.directory().to_path_buf(); + let current_path = tsconfig.path.clone(); + visited.insert(current_path.clone()); + for reference in &mut tsconfig.references { + let reference_tsconfig_path = directory.normalize_with(&reference.path); + // Reborrow so the closure below can capture `&mut FxHashSet` across + // multiple iterations of this loop. Without the reborrow, the first + // iteration would consume the original `&mut visited` binding. + let visited: &mut FxHashSet = &mut *visited; + let reference_tsconfig = self + .cache + .tsconfig( + /* root */ true, + &reference_tsconfig_path, + |mut reference_tsconfig| { + let current_path = current_path.clone(); + async move { + if reference_tsconfig.path == current_path { + return Err(ResolveError::TsconfigSelfReference( + reference_tsconfig.path.clone(), + )); + } + // Cut the cycle: if this reference is already part of the + // ongoing load chain, return it without walking its own + // references. Its `paths` are still attached to the parent. + if visited.contains(&reference_tsconfig.path) { + return Ok(reference_tsconfig); + } + // Apply `extends` so the reference inherits its base config's + // `baseUrl`/`paths` before its own references are walked. + let directory = self.cache.value(reference_tsconfig.directory()); + self + .merge_tsconfig_extends(&mut reference_tsconfig, &directory) + .await?; + self + .load_references(&mut reference_tsconfig, visited) + .await?; + Ok(reference_tsconfig) + } + }, + ) + .await?; + tsconfig + .file_dependencies + .extend(reference_tsconfig.file_dependencies.iter().cloned()); + reference.tsconfig.replace(reference_tsconfig); + } + Ok(()) + }) + } + + async fn merge_tsconfig_extends( + &self, + tsconfig: &mut TsConfig, + directory: &CachedPath, + ) -> Result<(), ResolveError> { + let Some(extends) = &tsconfig.extends else { + return Ok(()); + }; + let extended_tsconfig_paths = match extends { + ExtendsField::Single(s) => { + vec![ + self + .get_extended_tsconfig_path(directory, tsconfig, s) + .await?, + ] + } + ExtendsField::Multiple(specifiers) => { + try_join_all( + specifiers + .iter() + .map(|s| self.get_extended_tsconfig_path(directory, tsconfig, s)), + ) + .await? + } + }; + for extended_tsconfig_path in extended_tsconfig_paths { + let extended_tsconfig = self + .load_tsconfig( + /* root */ false, + &extended_tsconfig_path, + &TsconfigReferences::Disabled, + ) + .await?; + tsconfig.extend_tsconfig(&extended_tsconfig); + } + Ok(()) + } + async fn get_extended_tsconfig_path( &self, directory: &CachedPath, diff --git a/src/tests/tsconfig_project_references.rs b/src/tests/tsconfig_project_references.rs index 44455729..9571b892 100644 --- a/src/tests/tsconfig_project_references.rs +++ b/src/tests/tsconfig_project_references.rs @@ -184,3 +184,110 @@ async fn self_reference() { ); } } + +// Transitive project references: A → B → C. +// When the entry tsconfig (A) declares `references: [B]` and B declares +// `references: [C]`, a file inside C must resolve via C's own `paths` +// (matching tsc's "nearest tsconfig wins" behavior and webpack's +// recursive `references: "auto"` walk). +#[tokio::test] +async fn transitive_references() { + let f = super::fixture_root().join("tsconfig/cases/references-transitive"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigOptions { + config_file: f.join("app"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + let cases = [ + // Direct: file in app uses app's paths. + (f.join("app"), "@/index.ts", f.join("app/aliased/index.ts")), + // One level: file in project_b uses project_b's paths (baseUrl ./src). + ( + f.join("project_b/src"), + "@/index.ts", + f.join("project_b/src/aliased/index.ts"), + ), + // Two levels: file in project_c (referenced by project_b which is + // referenced by app) uses project_c's paths. + ( + f.join("project_c"), + "@/index.ts", + f.join("project_c/aliased/index.ts"), + ), + ]; + + for (path, request, expected) in cases { + let resolved_path = resolver + .resolve(&path, request) + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(expected), "{request} from {path:?}"); + } +} + +// When a project reference uses `extends` to inherit its `baseUrl`/`paths` +// from a shared base config, those fields must be merged before the +// reference is consulted for resolution. Without merging `extends` on +// referenced configs, a request from inside `project_b` would see no +// alias candidates and fail to resolve. +#[tokio::test] +async fn references_with_extends() { + let f = super::fixture_root().join("tsconfig/cases/references-extends"); + + let resolver = Resolver::new(ResolveOptions { + tsconfig: Some(TsconfigOptions { + config_file: f.join("app"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + // From project_b's directory, the inherited `paths` from + // ../tsconfig.base.json must apply (baseUrl ./src). + let resolved_path = resolver + .resolve(&f.join("project_b/src"), "@/index.ts") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("project_b/src/aliased/index.ts"))); + + // The entry tsconfig still uses its own `paths`. + let resolved_path = resolver + .resolve(&f.join("app"), "@/index.ts") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("app/aliased/index.ts"))); +} + +// A pair of project references that form a cycle (a → b → a) must not +// cause infinite recursion / stack overflow when `references: "auto"` +// recursively walks the graph. Each project's own `paths` should still +// be honored from within its own directory. +#[tokio::test] +async fn cyclic_references() { + let f = super::fixture_root().join("tsconfig/cases/references-cycle"); + + let resolver = Resolver::new(ResolveOptions { + extensions: vec![".ts".into()], + tsconfig: Some(TsconfigOptions { + config_file: f.join("a"), + references: TsconfigReferences::Auto, + }), + ..ResolveOptions::default() + }); + + let resolved_path = resolver + .resolve(&f.join("a"), "@a/index") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("a/src/index.ts"))); + + let resolved_path = resolver + .resolve(&f.join("b"), "@b/index") + .await + .map(|p| p.full_path()); + assert_eq!(resolved_path, Ok(f.join("b/src/index.ts"))); +} diff --git a/src/tsconfig.rs b/src/tsconfig.rs index 7286790f..a803a5af 100644 --- a/src/tsconfig.rs +++ b/src/tsconfig.rs @@ -170,17 +170,31 @@ impl TsConfig { } pub fn resolve(&self, path: &Path, specifier: &str) -> Vec { + if let Some(matched) = self.find_reference_paths(path, specifier) { + return matched; + } + self.resolve_path_alias(specifier) + } + + // Walks `references` recursively, returning the nearest reference whose + // `base_path` contains `path`. Used to honor transitive project references + // (A → B → C): a file inside C should resolve via C's own `paths` even + // when the entry tsconfig is A and only B is listed directly in A's + // references. Matches `tsc`'s "nearest tsconfig wins" semantics. + fn find_reference_paths(&self, path: &Path, specifier: &str) -> Option> { for tsconfig in self .references .iter() .filter_map(|reference| reference.tsconfig.as_ref()) { + if let Some(nested) = tsconfig.find_reference_paths(path, specifier) { + return Some(nested); + } if path.starts_with(tsconfig.base_path()) { - return tsconfig.resolve_path_alias(specifier); + return Some(tsconfig.resolve_path_alias(specifier)); } } - - self.resolve_path_alias(specifier) + None } // Copied from parcel