diff --git a/Cargo.lock b/Cargo.lock index 84fa82d3..1b6ad76e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,6 +44,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "autocfg" version = "1.5.0" @@ -387,6 +393,15 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fast-glob" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d26eec0ae9682c457cb0f85de67ad417b716ae852736a5d94c2ad6e92a997c9" +dependencies = [ + "arrayvec", +] + [[package]] name = "filetime" version = "0.2.26" @@ -856,6 +871,7 @@ dependencies = [ "dirs", "document-features", "fancy-regex", + "fast-glob", "indexmap", "json-strip-comments", "once_cell", diff --git a/Cargo.toml b/Cargo.toml index 4432d3d4..834ee9c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ name = "resolver" [dependencies] cfg-if = "1" +fast-glob = "1.0.0" indexmap = { version = "2", features = ["serde"] } json-strip-comments = "3" once_cell = "1" # Use `std::sync::OnceLock::get_or_try_init` when it is stable. diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-a/src/index.ts b/fixtures/tsconfig/cases/solution_style/packages/pkg-a/src/index.ts new file mode 100644 index 00000000..3f0f1982 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-a/src/index.ts @@ -0,0 +1 @@ +export const pkgAIndex = 'pkg-a'; diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-a/tsconfig.json b/fixtures/tsconfig/cases/solution_style/packages/pkg-a/tsconfig.json new file mode 100644 index 00000000..1afa7b7a --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-a/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "paths": { + "@pkg-a/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.js b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.js new file mode 100644 index 00000000..4cac34ba --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.js @@ -0,0 +1 @@ +export const pkgBIndexJS = 'pkg-b-js'; diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.ts b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.ts new file mode 100644 index 00000000..32fb0fa5 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/src/index.ts @@ -0,0 +1 @@ +export const pkgBIndex = 'pkg-b'; diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-b/tsconfig.json b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/tsconfig.json new file mode 100644 index 00000000..784d1552 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "allowJs": true, + "paths": { + "@pkg-b/*": ["./src/*"] + } + }, + "include": ["src/**/*"] +} diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.test.ts b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.test.ts new file mode 100644 index 00000000..9b00c7d8 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.test.ts @@ -0,0 +1 @@ +// This file should be excluded by pkg-c's exclude pattern diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.ts b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.ts new file mode 100644 index 00000000..31b49279 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/lib/index.ts @@ -0,0 +1 @@ +export const pkgCIndex = 'pkg-c'; diff --git a/fixtures/tsconfig/cases/solution_style/packages/pkg-c/tsconfig.json b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/tsconfig.json new file mode 100644 index 00000000..0d2bedcf --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/packages/pkg-c/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "baseUrl": ".", + "paths": { + "@pkg-c/*": ["./lib/*"] + } + }, + "include": ["lib/**/*"], + "exclude": ["lib/**/*.test.ts"] +} diff --git a/fixtures/tsconfig/cases/solution_style/src/index.ts b/fixtures/tsconfig/cases/solution_style/src/index.ts new file mode 100644 index 00000000..890ca670 --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/src/index.ts @@ -0,0 +1 @@ +export const rootIndex = 'root'; diff --git a/fixtures/tsconfig/cases/solution_style/tsconfig.json b/fixtures/tsconfig/cases/solution_style/tsconfig.json new file mode 100644 index 00000000..9488ae2f --- /dev/null +++ b/fixtures/tsconfig/cases/solution_style/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "allowJs": true, + "paths": { + "root/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "references": [ + { "path": "./packages/pkg-a" }, + { "path": "./packages/pkg-b" }, + { "path": "./packages/pkg-c" } + ] +} diff --git a/src/lib.rs b/src/lib.rs index 1d23dd52..91c163e7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -199,13 +199,16 @@ impl ResolverGeneric { /// /// * See [ResolveError] pub fn resolve_tsconfig>(&self, path: P) -> Result, ResolveError> { + self.resolve_tsconfig_with_references(path, &TsconfigReferences::Auto) + } + + pub fn resolve_tsconfig_with_references>( + &self, + path: P, + references: &TsconfigReferences, + ) -> Result, ResolveError> { let path = path.as_ref(); - self.load_tsconfig( - true, - path, - &TsconfigReferences::Auto, - &mut TsconfigResolveContext::default(), - ) + self.load_tsconfig(true, path, references, &mut TsconfigResolveContext::default()) } /// Resolve `specifier` at absolute `path` with [ResolveContext] @@ -1469,7 +1472,7 @@ impl ResolverGeneric { let Some(tsconfig) = self.find_tsconfig(cached_path, ctx)? else { return Ok(None); }; - tsconfig + tsconfig.resolve_for_file(cached_path.path()) } }; diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 68054156..84d92ab3 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -28,6 +28,7 @@ mod tsconfig_discovery; mod tsconfig_extends; mod tsconfig_paths; mod tsconfig_project_references; +mod tsconfig_solution_style; #[cfg(target_os = "windows")] mod windows; diff --git a/src/tests/tsconfig_solution_style.rs b/src/tests/tsconfig_solution_style.rs new file mode 100644 index 00000000..6a63530f --- /dev/null +++ b/src/tests/tsconfig_solution_style.rs @@ -0,0 +1,174 @@ +//! Tests for solution-style tsconfig resolution +//! +//! Tests the `TsConfig::resolve_for_file` method which implements solution-style +//! tsconfig resolution similar to tsconfck's `resolveSolutionTSConfig`. + +use std::{path::Path, sync::Arc}; + +use crate::{ResolveOptions, Resolver, TsConfig}; + +/// Helper function to load a tsconfig with references +fn load_tsconfig(path: &Path) -> Arc { + let resolver = Resolver::new(ResolveOptions::default()); + resolver.resolve_tsconfig(path).unwrap() +} + +#[test] +fn resolve_for_file_basic() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + + // Load root tsconfig with references + let root_tsconfig = load_tsconfig(&f.join("tsconfig.json")); + + // File in root src/ should use root tsconfig + let file_path = f.join("src/index.ts"); + let resolved = root_tsconfig.resolve_for_file(&file_path); + assert_eq!(resolved.path(), root_tsconfig.path(), "File in src/ should use root tsconfig"); + + // File in pkg-a should use pkg-a's tsconfig + let file_path = f.join("packages/pkg-a/src/index.ts"); + let resolved = root_tsconfig.resolve_for_file(&file_path); + assert_eq!( + resolved.path(), + f.join("packages/pkg-a/tsconfig.json"), + "File in pkg-a/src/ should use pkg-a's tsconfig" + ); + + // File in pkg-b should use pkg-b's tsconfig + let file_path = f.join("packages/pkg-b/src/index.ts"); + let resolved = root_tsconfig.resolve_for_file(&file_path); + assert_eq!( + resolved.path(), + f.join("packages/pkg-b/tsconfig.json"), + "File in pkg-b/src/ should use pkg-b's tsconfig" + ); + + // File in pkg-c's lib/ should use pkg-c's tsconfig + let file_path = f.join("packages/pkg-c/lib/index.ts"); + let resolved = root_tsconfig.resolve_for_file(&file_path); + assert_eq!( + resolved.path(), + f.join("packages/pkg-c/tsconfig.json"), + "File in pkg-c/lib/ should use pkg-c's tsconfig" + ); +} + +#[test] +fn resolve_for_file_exclude_patterns() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + let root_tsconfig = load_tsconfig(&f.join("tsconfig.json")); + + // pkg-c excludes *.test.ts files + let excluded_file = f.join("packages/pkg-c/lib/index.test.ts"); + let resolved = root_tsconfig.resolve_for_file(&excluded_file); + + // Should fall back to root tsconfig since file is excluded from pkg-c + assert_eq!( + resolved.path(), + root_tsconfig.path(), + "Excluded files should fall back to root tsconfig" + ); + + // Non-excluded file should still use pkg-c's tsconfig + let included_file = f.join("packages/pkg-c/lib/index.ts"); + let resolved = root_tsconfig.resolve_for_file(&included_file); + assert_eq!( + resolved.path(), + f.join("packages/pkg-c/tsconfig.json"), + "Non-excluded files should use pkg-c's tsconfig" + ); +} + +#[test] +fn resolve_for_file_allowjs() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + let root_tsconfig = load_tsconfig(&f.join("tsconfig.json")); + + // pkg-b has allowJs: true, so .js files should use pkg-b's tsconfig + let js_file = f.join("packages/pkg-b/src/index.js"); + let resolved = root_tsconfig.resolve_for_file(&js_file); + assert_eq!( + resolved.path(), + f.join("packages/pkg-b/tsconfig.json"), + "With allowJs, .js files should use pkg-b's tsconfig" + ); + + // pkg-a doesn't have allowJs, so .js files should fall back to root + let js_file = f.join("packages/pkg-a/src/script.js"); + let resolved = root_tsconfig.resolve_for_file(&js_file); + assert_eq!( + resolved.path(), + root_tsconfig.path(), + "Without allowJs, .js files should fall back to root tsconfig" + ); +} + +#[test] +fn resolve_for_file_non_ts_js_files() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + let root_tsconfig = load_tsconfig(&f.join("tsconfig.json")); + + // Non-TS/JS files should not trigger solution-style resolution + let json_file = f.join("packages/pkg-a/src/data.json"); + let resolved = root_tsconfig.resolve_for_file(&json_file); + assert_eq!(resolved.path(), root_tsconfig.path(), "Non-TS/JS files should use root tsconfig"); + + // .css, .html, etc. should also fall back to root + let css_file = f.join("packages/pkg-a/src/styles.css"); + let resolved = root_tsconfig.resolve_for_file(&css_file); + assert_eq!(resolved.path(), root_tsconfig.path(), "CSS files should use root tsconfig"); +} + +#[test] +fn resolve_for_file_no_references() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + + // Load pkg-a's tsconfig which has no references + let pkg_a_tsconfig = load_tsconfig(&f.join("packages/pkg-a/tsconfig.json")); + + // When a tsconfig has no references, it should always return itself + let file_in_pkg_a = f.join("packages/pkg-a/src/index.ts"); + let resolved = pkg_a_tsconfig.resolve_for_file(&file_in_pkg_a); + assert_eq!( + resolved.path(), + pkg_a_tsconfig.path(), + "Tsconfig without references should always return itself" + ); + + // Even for files outside its directory + let file_in_pkg_b = f.join("packages/pkg-b/src/index.ts"); + let resolved = pkg_a_tsconfig.resolve_for_file(&file_in_pkg_b); + assert_eq!( + resolved.path(), + pkg_a_tsconfig.path(), + "Tsconfig without references should return itself for any file" + ); +} + +#[test] +fn resolve_for_file_extensions() { + let f = super::fixture_root().join("tsconfig/cases/solution_style"); + let root_tsconfig = load_tsconfig(&f.join("tsconfig.json")); + + // Test all TS extensions + for ext in ["ts", "tsx", "mts", "cts"] { + let file = f.join(format!("packages/pkg-a/src/index.{ext}")); + let resolved = root_tsconfig.resolve_for_file(&file); + assert_eq!( + resolved.path(), + f.join("packages/pkg-a/tsconfig.json"), + ".{ext} files should trigger solution-style resolution" + ); + } + + // Test JS extensions with allowJs + for ext in ["js", "jsx", "mjs", "cjs"] { + let file = f.join(format!("packages/pkg-b/src/index.{ext}")); + let resolved = root_tsconfig.resolve_for_file(&file); + assert_eq!( + resolved.path(), + f.join("packages/pkg-b/tsconfig.json"), + ".{ext} files should trigger solution-style resolution with allowJs" + ); + } +} diff --git a/src/tsconfig.rs b/src/tsconfig.rs index 4cdf5bd8..b0821ac6 100644 --- a/src/tsconfig.rs +++ b/src/tsconfig.rs @@ -1,4 +1,5 @@ use std::{ + borrow::Cow, fmt::Debug, hash::BuildHasherDefault, path::{Path, PathBuf}, @@ -13,6 +14,10 @@ use crate::{TsconfigReferences, path::PathUtil}; const TEMPLATE_VARIABLE: &str = "${configDir}"; +const DEFAULT_INCLUDE: &[&str] = &["**/*"]; +const DEFAULT_EXCLUDE: &[&str] = &["node_modules", "bower_components", "jspm_packages"]; +const TS_JS_EXTENSIONS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"]; + pub type CompilerOptionsPathsMap = IndexMap, BuildHasherDefault>; #[derive(Debug, Deserialize)] @@ -356,6 +361,149 @@ impl TsConfig { paths } + /// Resolve the correct tsconfig for a file in a solution-style project. + /// + /// In a TypeScript solution (monorepo with project references), the nearest tsconfig + /// might not be the correct one to use. If the file is not included in the found + /// tsconfig (based on include/exclude/files), we should search through the referenced + /// tsconfigs to find the one that actually includes this file. + /// + /// This implements similar logic to tsconfck's `resolveSolutionTSConfig`. + /// + /// # Arguments + /// * `file_path` - The path being resolved + /// + /// # Returns + /// The correct tsconfig to use for this file (either the original or a referenced one) + #[must_use] + pub fn resolve_for_file(self: &Arc, file_path: &Path) -> Arc { + if self.references.is_empty() { + return Arc::clone(self); + } + let is_not_ts_js_file = + file_path.extension().and_then(|ext| ext.to_str()).is_none_or(|ext| { + let extensions = if self.compiler_options().allow_js.unwrap_or_default() { + TS_JS_EXTENSIONS + } else { + &TS_JS_EXTENSIONS[..4] + }; + !extensions.contains(&ext) + }); + if is_not_ts_js_file { + return Arc::clone(self); + } + if self.is_file_included(file_path) { + return Arc::clone(self); + } + for reference in self.references() { + if let Some(tsconfig) = reference.tsconfig() { + if tsconfig.is_file_included(file_path) { + return Arc::clone(&tsconfig); + } + } + } + Arc::clone(self) + } + + /// Check if a file is included in this tsconfig based on files/include/exclude patterns. + /// + /// Follows TypeScript's file inclusion rules: + /// 1. Files listed in "files" array are always included + /// 2. Files matching "include" patterns are included + /// 3. Files matching "exclude" patterns are excluded (unless in "files") + /// + /// # Arguments + /// * `file_path` - Absolute path to the file to check + /// + /// # Returns + /// `true` if the file should be included, `false` otherwise + #[allow(clippy::option_if_let_else)] + pub(crate) fn is_file_included(&self, file_path: &Path) -> bool { + let tsconfig_dir = self.directory(); + + // 1. Check files array (highest priority - overrides exclude) + if let Some(files) = &self.files { + for file in files { + if file_path == tsconfig_dir.normalize_with(file) { + return true; + } + } + } + + // 2. Check include patterns (default to ["**/*"] if no files and no include) + let is_included = match &self.include { + Some(include_patterns) => self.is_glob_match(file_path, include_patterns.as_slice()), + None => self.files.is_none() && self.is_glob_match(file_path, DEFAULT_INCLUDE), + }; + if !is_included { + return false; + } + + // 3. Check exclude patterns (default excludes node_modules, etc.) + let is_excluded = match &self.exclude { + Some(exclude_patterns) => self.is_glob_match(file_path, exclude_patterns.as_slice()), + None => self.is_glob_match(file_path, DEFAULT_EXCLUDE), + }; + !is_excluded + } + + /// Match a file path against glob patterns. + /// + /// Implements a simplified version of TypeScript's glob matching logic, + /// based on tsconfck's implementation. + /// + /// # Arguments + /// * `file_path` - Absolute path to match + /// * `patterns` - Glob patterns to match against (can be `&[String]` or `&[&str]`) + /// + /// # Returns + /// `true` if any pattern matches the file + pub(crate) fn is_glob_match>(&self, file_path: &Path, patterns: &[S]) -> bool { + let Ok(relative_path) = file_path.strip_prefix(self.directory()) else { + return false; + }; + let extensions = if self.compiler_options.allow_js.unwrap_or_default() { + TS_JS_EXTENSIONS + } else { + &TS_JS_EXTENSIONS[..4] + }; + patterns.iter().any(|pattern| { + let pattern = pattern.as_ref(); + + // Special case: **/* matches everything + if pattern == "**/*" { + return true; + } + + // Normalize pattern: add implicit /**/* for directory patterns + // Find the part after the last '/' to check if it looks like a directory + let after_last_slash = pattern.rsplit('/').next().unwrap_or(pattern); + let needs_implicit_glob = !after_last_slash.contains(['.', '*', '?']); + + let pattern = if needs_implicit_glob { + Cow::Owned(format!( + "{pattern}{}", + if pattern.ends_with('/') { "**/*" } else { "/**/*" } + )) + } else { + Cow::Borrowed(pattern) + }; + + // Fast check: if pattern ends with *, filename must have valid extension + if pattern.ends_with('*') { + let has_valid_ext = relative_path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| extensions.contains(&ext)); + if !has_valid_ext { + return false; + } + } + + fast_glob::glob_match(pattern.as_ref(), relative_path.as_os_str().as_encoded_bytes()) + }) + } + /// Resolves the given `specifier` within the project configured by this /// tsconfig. ///