Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { foo } from "@child/foo";

foo;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = "foo";
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"compilerOptions": {
"composite": true,
"paths": { "@child/*": ["./src/*"] }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"compilerOptions": { "composite": true },
"files": [],
"references": [{ "path": "../child" }]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./parent" }]
}
27 changes: 17 additions & 10 deletions src/cache/cache_impl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,22 @@ impl<Fs: FileSystem> Cache<Fs> {
.cloned()
}

/// Resolves a tsconfig spec path (file, directory, or extensionless) to
/// the on-disk `tsconfig.json` path. Same logic the cache uses internally
/// before parsing.
pub(crate) fn resolve_tsconfig_path<'a>(&self, path: &'a Path) -> Cow<'a, Path> {
let meta = self.fs.metadata(path).ok();
if meta.is_some_and(|m| m.is_file) {
Cow::Borrowed(path)
} else if meta.is_some_and(|m| m.is_dir) {
Cow::Owned(path.join("tsconfig.json"))
} else {
let mut os_string = path.to_path_buf().into_os_string();
os_string.push(".json");
Cow::Owned(PathBuf::from(os_string))
}
}

pub(crate) fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
&self,
root: bool,
Expand All @@ -216,16 +232,7 @@ impl<Fs: FileSystem> Cache<Fs> {
}

// Not in any cache, parse from file
let meta = self.fs.metadata(path).ok();
let tsconfig_path = if meta.is_some_and(|m| m.is_file) {
Cow::Borrowed(path)
} else if meta.is_some_and(|m| m.is_dir) {
Cow::Owned(path.join("tsconfig.json"))
} else {
let mut os_string = path.to_path_buf().into_os_string();
os_string.push(".json");
Cow::Owned(PathBuf::from(os_string))
};
let tsconfig_path = self.resolve_tsconfig_path(path);
let tsconfig_string = self.fs.read_to_string(&tsconfig_path).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
ResolveError::TsconfigNotFound(path.to_path_buf())
Expand Down
18 changes: 18 additions & 0 deletions src/tests/tsconfig_project_references.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,3 +219,21 @@ fn root_paths_apply_to_default_include_files() {
resolver.resolve_file(f.join("index.ts"), "@app/util").map(|f| f.full_path());
assert_eq!(resolved_path, Ok(f.join("src/util.ts")));
}

#[test]
fn transitive_reference_owns_file_and_paths_apply() {
let f = super::fixture_root().join("tsconfig/cases/project-references-transitive");

let resolver = Resolver::new(ResolveOptions {
extensions: vec![".ts".into()],
tsconfig: Some(TsconfigDiscovery::Manual(TsconfigOptions {
config_file: f.clone(),
references: TsconfigReferences::Auto,
})),
..ResolveOptions::default()
});

let resolved_path =
resolver.resolve_file(f.join("child/src/bar.ts"), "@child/foo").map(|f| f.full_path());
assert_eq!(resolved_path, Ok(f.join("child/src/foo.ts")));
}
69 changes: 56 additions & 13 deletions src/tsconfig.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{
borrow::Cow,
cmp::Reverse,
collections::VecDeque,
fmt::Debug,
hash::BuildHasherDefault,
path::{Path, PathBuf},
Expand Down Expand Up @@ -560,36 +561,78 @@ impl TsConfig {
pub(crate) fn resolve_tsconfig_solution(tsconfig: Arc<Self>, path: &Path) -> Arc<Self> {
if !tsconfig.references_resolved.is_empty()
&& tsconfig.is_file_extension_allowed_in_tsconfig(path)
&& let Some(solution_tsconfig) = tsconfig
.references_resolved
.iter()
.find(|referenced| referenced.is_file_included_in_tsconfig(path))
.map(Arc::clone)
&& let Some(solution_tsconfig) = tsconfig.find_deepest_reference_owning(path)
{
return solution_tsconfig;
}
tsconfig
}

/// Whether this tsconfig (directly or via a referenced sub-project) claims
/// ownership of `path`. Used by tsconfig auto-discovery to decide whether
/// to keep walking up to an ancestor `tsconfig.json` when the nearest one
/// doesn't actually cover the file via its `files` / `include` / `exclude`
/// or via a matching reference.
/// Walks the project-reference graph breadth-first and returns the deepest
/// referenced tsconfig that owns `path`. Mirrors typescript-go's
/// `initMapperWorker` (internal/compiler/projectreferenceparser.go), where
/// `maps.Copy` lets child entries overwrite parent entries — so when both
/// a parent and a nested reference claim the same file, the deeper one
/// wins (it has the smaller / more specific program).
fn find_deepest_reference_owning(&self, path: &Path) -> Option<Arc<Self>> {
let mut deepest: Option<(usize, Arc<Self>)> = None;
let mut visited: FxHashSet<PathBuf> = FxHashSet::default();
visited.insert(self.path.clone());
let mut queue: VecDeque<(usize, &Arc<Self>)> =
self.references_resolved.iter().map(|r| (1, r)).collect();
while let Some((depth, current)) = queue.pop_front() {
if !visited.insert(current.path.clone()) {
continue;
}
if current.is_file_included_in_tsconfig(path)
&& deepest.as_ref().is_none_or(|(d, _)| *d < depth)
{
deepest = Some((depth, Arc::clone(current)));
}
for child in &current.references_resolved {
queue.push_back((depth + 1, child));
}
}
deepest.map(|(_, tsconfig)| tsconfig)
}

/// Whether this tsconfig (directly or via a referenced sub-project, at any
/// depth) claims ownership of `path`. Used by tsconfig auto-discovery to
/// decide whether to keep walking up to an ancestor `tsconfig.json` when
/// the nearest one doesn't actually cover the file via its `files` /
/// `include` / `exclude` or via a matching reference.
pub(crate) fn claims_ownership_of(&self, path: &Path) -> bool {
// Non-TS files aren't considered for project ownership; preserve the
// legacy behavior of using the nearest tsconfig for them.
if !self.is_file_extension_allowed_in_tsconfig(path) {
return true;
}
// Any matching reference claims ownership (consistent with
// resolve_tsconfig_solution).
if self.references_resolved.iter().any(|r| r.is_file_included_in_tsconfig(path)) {
// Any matching reference (direct or transitive) claims ownership —
// consistent with `resolve_tsconfig_solution`.
if self.any_transitive_reference_owns(path) {
return true;
}
self.is_file_included_in_tsconfig(path)
}

fn any_transitive_reference_owns(&self, path: &Path) -> bool {
let mut visited: FxHashSet<PathBuf> = FxHashSet::default();
visited.insert(self.path.clone());
let mut queue: VecDeque<&Arc<Self>> = self.references_resolved.iter().collect();
while let Some(current) = queue.pop_front() {
if !visited.insert(current.path.clone()) {
continue;
}
if current.is_file_included_in_tsconfig(path) {
return true;
}
for child in &current.references_resolved {
queue.push_back(child);
}
}
false
}

fn is_file_included_in_tsconfig(&self, path: &Path) -> bool {
// Primary source of truth: the precomputed set from `populate_owned_files`,
// mirroring typescript-go's `FileNames` map. For paths that don't exist on
Expand Down
95 changes: 50 additions & 45 deletions src/tsconfig_resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ use crate::{
#[derive(Default)]
pub struct TsconfigResolveContext {
extended_configs: Vec<PathBuf>,
/// Project-reference targets currently mid-load. Used to break cycles in
/// the reference graph (`a → b → a`). Mirrors typescript-go's `seen` set
/// in `initMapperWorker`.
loading_references: Vec<PathBuf>,
}

impl TsconfigResolveContext {
Expand All @@ -32,6 +36,17 @@ impl TsconfigResolveContext {
new_vec.push(path);
new_vec
}

fn with_loading_reference<R, T: FnOnce(&mut Self) -> R>(&mut self, path: PathBuf, cb: T) -> R {
self.loading_references.push(path);
let result = cb(self);
self.loading_references.pop();
result
}

fn is_loading_reference(&self, path: &Path) -> bool {
self.loading_references.iter().any(|p| p == path)
}
}

impl<Fs: FileSystem> ResolverGeneric<Fs> {
Expand Down Expand Up @@ -229,56 +244,46 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
if tsconfig.load_references(references) {
let path = tsconfig.path().to_path_buf();
let directory = tsconfig.directory().to_path_buf();
for reference in &tsconfig.references {
let reference_tsconfig_path = directory.normalize_with(&reference.path);
let referenced_tsconfig = self.cache.get_tsconfig(
/* root */ true,
&reference_tsconfig_path,
|reference_tsconfig| {
if reference_tsconfig.path() == path {
return Err(ResolveError::TsconfigSelfReference(
reference_tsconfig.path().to_path_buf(),
));
}
self.extend_tsconfig(
&self.cache.value(reference_tsconfig.directory()),
reference_tsconfig,
ctx,
)?;
Ok(())
},
)?;
tsconfig.references_resolved.push(referenced_tsconfig);
}
// Pre-resolve each reference input (which may be a directory,
// a file, or an extensionless spec) to the actual
// `tsconfig.json` file path, so direct self-references and
// graph cycles can be detected on the canonical key.
let reference_inputs = tsconfig
.references
.iter()
.map(|reference| {
let input = directory.normalize_with(&reference.path);
let file = self.cache.resolve_tsconfig_path(&input).into_owned();
(input, file)
})
.collect::<Vec<_>>();
ctx.with_loading_reference(path.clone(), |ctx| {
for (reference_input, reference_file_path) in reference_inputs {
if reference_file_path == path {
return Err(ResolveError::TsconfigSelfReference(reference_file_path));
}
// Cycle in the reference graph (a → b → a, etc.).
// typescript-go's `initMapperWorker` breaks the cycle
// by skipping; the already-loading tsconfig is wired
// up by its own (earlier) load frame.
if ctx.is_loading_reference(&reference_file_path) {
continue;
}
let referenced_tsconfig = self.load_tsconfig(
/* root */ true,
&reference_input,
references,
ctx,
)?;
tsconfig.references_resolved.push(referenced_tsconfig);
Comment on lines +272 to +278

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Traverse transitive references during manual directory resolution

When a manual tsconfig is used with the directory-based resolve() API, this recursive load still leaves grandchildren nested under their parent, but resolve_references_then_self_paths only scans the root's direct references_resolved. In a graph like the new fixture (root -> parent -> child), resolver.resolve(child/src, "@child/foo") will never consult the child's paths and will fall back to the root config, even though resolve_file() passes because it selects the child solution first. The transitive graph needs to be traversed or flattened for this path-selection code as well.

Useful? React with 👍 / 👎.

}
Result::Ok::<(), ResolveError>(())
})?;
}
Ok(())
})
}

fn extend_tsconfig(
&self,
directory: &CachedPath,
tsconfig: &mut TsConfig,
ctx: &mut TsconfigResolveContext,
) -> Result<(), ResolveError> {
let extended_tsconfig_paths = tsconfig
.extends()
.map(|specifier| self.get_extended_tsconfig_path(directory, tsconfig, specifier))
.collect::<Result<Vec<_>, _>>()?;
// Iterate in reverse so that later `extends` entries take precedence —
// see comment in `load_tsconfig`.
for extended_tsconfig_path in extended_tsconfig_paths.into_iter().rev() {
let extended_tsconfig = self.load_tsconfig(
/* root */ false,
&extended_tsconfig_path,
TsconfigReferences::Disabled,
ctx,
)?;
tsconfig.extend_tsconfig(&extended_tsconfig);
}
Ok(())
}

/// Resolves
/// * `compilerOptions.paths`
/// * `compilerOptions.rootDirs` (if specifier starts with `.`)
Expand Down
Loading