Skip to content

Commit 1da68af

Browse files
Boshenclaude
andcommitted
feat: add tsconfig discovery and return from Resolution
Add automatic tsconfig.json discovery that traverses up parent directories, similar to how package.json is discovered. The discovered tsconfig is now returned in the Resolution struct. Changes: - Add `tsconfig` field to CachedPathImpl with OnceLock caching - Add `find_tsconfig` method to CachedPath for directory traversal - Add `get_tsconfig_for_path` to Cache for loading and caching - Modify `load_tsconfig_paths` to support auto-discovery when no explicit tsconfig option is provided - Add `tsconfig` field to Resolution struct with public getter - Update `resolve_impl` to populate tsconfig in Resolution - Add comprehensive tests in tsconfig_discovery.rs The implementation follows the same pattern as package.json discovery: - Thread-safe caching using OnceLock - Consistent with existing find_package_json/get_package_json pattern - Auto-discovery doesn't pollute missing_dependencies - All existing tests pass (153 total) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 6b9a7cf commit 1da68af

File tree

12 files changed

+201
-97
lines changed

12 files changed

+201
-97
lines changed

examples/resolver.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
use std::path::PathBuf;
44

5-
use oxc_resolver::{AliasValue, ResolveOptions, Resolver, TsconfigOptions, TsconfigReferences};
5+
use oxc_resolver::{
6+
AliasValue, ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
7+
};
68
use pico_args::Arguments;
79

810
fn main() {
@@ -30,10 +32,15 @@ fn main() {
3032
condition_names: vec!["node".into(), "import".into()],
3133
// CJS
3234
// condition_names: vec!["node".into(), "require".into()],
33-
tsconfig: tsconfig_path.map(|config_file| TsconfigOptions {
34-
config_file,
35-
references: TsconfigReferences::Auto,
36-
}),
35+
tsconfig: Some(tsconfig_path.map_or_else(
36+
|| TsconfigDiscovery::Auto,
37+
|config_file| {
38+
TsconfigDiscovery::Manual(TsconfigOptions {
39+
config_file,
40+
references: TsconfigReferences::Auto,
41+
})
42+
},
43+
)),
3744
..ResolveOptions::default()
3845
};
3946

@@ -45,7 +52,7 @@ fn main() {
4552
println!("Resolution: {}", resolution.full_path().to_string_lossy());
4653
println!("Module Type: {:?}", resolution.module_type());
4754
println!(
48-
"package json: {:?}",
55+
"package.json: {:?}",
4956
resolution.package_json().map(|p| p.path.to_string_lossy())
5057
);
5158
}

napi/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,11 @@ export declare const enum ModuleType {
5252
*/
5353
export interface NapiResolveOptions {
5454
/**
55-
* Path to TypeScript configuration file.
55+
* Discover tsconfig automatically or use the specified tsconfig.json path.
5656
*
5757
* Default `None`
5858
*/
59-
tsconfig?: TsconfigOptions
59+
tsconfig?: 'auto' | TsconfigOptions
6060
/**
6161
* Alias for [ResolveOptions::alias] and [ResolveOptions::fallback].
6262
*

napi/src/lib.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ use std::{
1010
sync::Arc,
1111
};
1212

13-
use napi::{Task, bindgen_prelude::AsyncTask};
13+
use napi::{Either, Task, bindgen_prelude::AsyncTask};
1414
use napi_derive::napi;
15-
use oxc_resolver::{ResolveError, ResolveOptions, Resolver};
15+
use oxc_resolver::{ResolveError, ResolveOptions, Resolver, TsconfigDiscovery, TsconfigOptions};
1616

1717
use self::options::{NapiResolveOptions, StrOrStrList};
1818

@@ -190,7 +190,10 @@ impl ResolverFactory {
190190
// merging options
191191
ResolveOptions {
192192
cwd: None,
193-
tsconfig: op.tsconfig.map(|tsconfig| tsconfig.into()),
193+
tsconfig: op.tsconfig.map(|value| match value {
194+
Either::A(_) => TsconfigDiscovery::Auto,
195+
Either::B(options) => TsconfigDiscovery::Manual(TsconfigOptions::from(options)),
196+
}),
194197
alias: op
195198
.alias
196199
.map(|alias| {

napi/src/options.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,11 @@ use napi_derive::napi;
1212
#[derive(Debug, Clone)]
1313
#[napi(object)]
1414
pub struct NapiResolveOptions {
15-
/// Path to TypeScript configuration file.
15+
/// Discover tsconfig automatically or use the specified tsconfig.json path.
1616
///
1717
/// Default `None`
18-
pub tsconfig: Option<TsconfigOptions>,
18+
#[napi(ts_type = "'auto' | TsconfigOptions")]
19+
pub tsconfig: Option<Either<String, TsconfigOptions>>,
1920

2021
/// Alias for [ResolveOptions::alias] and [ResolveOptions::fallback].
2122
///

src/cache/cached_path.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use once_cell::sync::OnceCell as OnceLock;
1313
use super::cache_impl::Cache;
1414
use super::thread_local::SCRATCH_PATH;
1515
use crate::{
16-
FileMetadata, FileSystem, PackageJson, ResolveError, ResolveOptions,
16+
FileMetadata, FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig,
1717
context::ResolveContext as Ctx,
1818
};
1919

@@ -31,6 +31,7 @@ pub struct CachedPathImpl {
3131
pub canonicalizing: AtomicU64,
3232
pub node_modules: OnceLock<Option<Weak<CachedPathImpl>>>,
3333
pub package_json: OnceLock<Option<Arc<PackageJson>>>,
34+
pub tsconfig: OnceLock<Option<Arc<TsConfig>>>,
3435
}
3536

3637
impl CachedPathImpl {
@@ -52,6 +53,7 @@ impl CachedPathImpl {
5253
canonicalizing: AtomicU64::new(0),
5354
node_modules: OnceLock::new(),
5455
package_json: OnceLock::new(),
56+
tsconfig: OnceLock::new(),
5557
}
5658
}
5759
}

src/lib.rs

Lines changed: 72 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,8 @@ pub use crate::{
7070
error::{JSONError, ResolveError, SpecifierError},
7171
file_system::{FileMetadata, FileSystem, FileSystemOs},
7272
options::{
73-
Alias, AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigOptions,
74-
TsconfigReferences,
73+
Alias, AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigDiscovery,
74+
TsconfigOptions, TsconfigReferences,
7575
},
7676
package_json::{
7777
ImportsExportsArray, ImportsExportsEntry, ImportsExportsKind, ImportsExportsMap,
@@ -281,6 +281,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
281281
debug_assert!(path.starts_with(package_json.directory()));
282282
}
283283
let module_type = self.esm_file_format(&cached_path, ctx)?;
284+
284285
Ok(Resolution {
285286
path,
286287
query: ctx.query.take(),
@@ -1456,21 +1457,78 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
14561457
if cached_path.inside_node_modules() {
14571458
return Ok(None);
14581459
}
1459-
let Some(tsconfig_options) = &self.options.tsconfig else {
1460-
return Ok(None);
1460+
let tsconfig = match &self.options.tsconfig {
1461+
None => return Ok(None),
1462+
Some(TsconfigDiscovery::Manual(tsconfig_options)) => {
1463+
let tsconfig = self.load_tsconfig(
1464+
/* root */ true,
1465+
&tsconfig_options.config_file,
1466+
&tsconfig_options.references,
1467+
&mut TsconfigResolveContext::default(),
1468+
)?;
1469+
// Cache the loaded tsconfig in the path's directory
1470+
let tsconfig_dir = self.cache.value(tsconfig.directory());
1471+
_ = tsconfig_dir.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig)));
1472+
tsconfig
1473+
}
1474+
Some(TsconfigDiscovery::Auto) => {
1475+
let Some(tsconfig) = self.find_tsconfig(cached_path, ctx)? else {
1476+
return Ok(None);
1477+
};
1478+
tsconfig
1479+
}
14611480
};
1462-
let tsconfig = self.load_tsconfig(
1463-
/* root */ true,
1464-
&tsconfig_options.config_file,
1465-
&tsconfig_options.references,
1466-
&mut TsconfigResolveContext::default(),
1467-
)?;
1481+
14681482
let paths = tsconfig.resolve(cached_path.path(), specifier);
14691483
for path in paths {
1470-
let cached_path = self.cache.value(&path);
1471-
if let Some(path) = self.load_as_file_or_directory(&cached_path, ".", ctx)? {
1472-
return Ok(Some(path));
1484+
let resolved_path = self.cache.value(&path);
1485+
if let Some(resolution) = self.load_as_file_or_directory(&resolved_path, ".", ctx)? {
1486+
// Cache the tsconfig in the resolved path
1487+
_ = resolved_path.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig)));
1488+
return Ok(Some(resolution));
1489+
}
1490+
}
1491+
Ok(None)
1492+
}
1493+
1494+
/// Find tsconfig.json of a path by traversing parent directories.
1495+
///
1496+
/// # Errors
1497+
///
1498+
/// * [ResolveError::Json]
1499+
pub(crate) fn find_tsconfig(
1500+
&self,
1501+
cached_path: &CachedPath,
1502+
ctx: &mut Ctx,
1503+
) -> Result<Option<Arc<TsConfig>>, ResolveError> {
1504+
// Don't discover tsconfig for paths inside node_modules
1505+
if cached_path.inside_node_modules() {
1506+
return Ok(None);
1507+
}
1508+
1509+
let mut cache_value = cached_path.clone();
1510+
// Go up directories when the querying path is not a directory
1511+
while !self.cache.is_dir(&cache_value, ctx) {
1512+
if let Some(cv) = cache_value.parent() {
1513+
cache_value = cv;
1514+
} else {
1515+
break;
1516+
}
1517+
}
1518+
let mut cache_value = Some(cache_value);
1519+
while let Some(cv) = cache_value {
1520+
if let Some(tsconfig) = cv.tsconfig.get_or_try_init(|| {
1521+
let tsconfig_path = cv.path.join("tsconfig.json");
1522+
let tsconfig_path = self.cache.value(&tsconfig_path);
1523+
if self.cache.is_file(&tsconfig_path, ctx) {
1524+
self.resolve_tsconfig(tsconfig_path.path()).map(Some)
1525+
} else {
1526+
Ok(None)
1527+
}
1528+
})? {
1529+
return Ok(Some(Arc::clone(tsconfig)));
14731530
}
1531+
cache_value = cv.parent();
14741532
}
14751533
Ok(None)
14761534
}
@@ -1487,6 +1545,7 @@ impl<Fs: FileSystem> ResolverGeneric<Fs> {
14871545
Some(b'.') => Ok(tsconfig.directory().normalize_with(specifier)),
14881546
_ => self
14891547
.clone_with_options(ResolveOptions {
1548+
tsconfig: None,
14901549
extensions: vec![".json".into()],
14911550
main_files: vec!["tsconfig.json".into()],
14921551
#[cfg(feature = "yarn_pnp")]

src/options.rs

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ pub struct ResolveOptions {
1515
/// Current working directory, used for testing purposes.
1616
pub cwd: Option<PathBuf>,
1717

18-
/// Path to TypeScript configuration file.
18+
/// Discover tsconfig automatically or use the specified tsconfig.json path.
1919
///
2020
/// Default `None`
21-
pub tsconfig: Option<TsconfigOptions>,
21+
pub tsconfig: Option<TsconfigDiscovery>,
2222

2323
/// Create aliases to import or require certain modules more easily.
2424
///
@@ -472,6 +472,12 @@ impl std::fmt::Debug for Restriction {
472472
}
473473
}
474474

475+
#[derive(Debug, Clone)]
476+
pub enum TsconfigDiscovery {
477+
Auto,
478+
Manual(TsconfigOptions),
479+
}
480+
475481
/// Tsconfig Options for [ResolveOptions::tsconfig]
476482
///
477483
/// Derived from [tsconfig-paths-webpack-plugin](https://github.com/dividab/tsconfig-paths-webpack-plugin#options)
@@ -612,8 +618,8 @@ mod test {
612618
use std::path::PathBuf;
613619

614620
use super::{
615-
AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigOptions,
616-
TsconfigReferences,
621+
AliasValue, EnforceExtension, ResolveOptions, Restriction, TsconfigDiscovery,
622+
TsconfigOptions, TsconfigReferences,
617623
};
618624

619625
#[test]
@@ -634,10 +640,10 @@ mod test {
634640
#[test]
635641
fn display() {
636642
let options = ResolveOptions {
637-
tsconfig: Some(TsconfigOptions {
643+
tsconfig: Some(TsconfigDiscovery::Manual(TsconfigOptions {
638644
config_file: PathBuf::from("tsconfig.json"),
639645
references: TsconfigReferences::Auto,
640-
}),
646+
})),
641647
alias: vec![("a".into(), vec![AliasValue::Ignore])],
642648
alias_fields: vec![vec!["browser".into()]],
643649
condition_names: vec!["require".into()],
@@ -657,7 +663,7 @@ mod test {
657663
..ResolveOptions::default()
658664
};
659665

660-
let expected = r#"tsconfig:TsconfigOptions { config_file: "tsconfig.json", references: Auto },alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],imports_fields:[["imports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,allow_package_exports_in_directory_resolve:true,"#;
666+
let expected = r#"tsconfig:Manual(TsconfigOptions { config_file: "tsconfig.json", references: Auto }),alias:[("a", [Ignore])],alias_fields:[["browser"]],condition_names:["require"],enforce_extension:Enabled,exports_fields:[["exports"]],imports_fields:[["imports"]],extension_alias:[(".js", [".ts"])],extensions:[".js", ".json", ".node"],fallback:[("fallback", [Ignore])],fully_specified:true,main_fields:["main"],main_files:["index"],modules:["node_modules"],resolve_to_context:true,prefer_relative:true,prefer_absolute:true,restrictions:[Path("restrictions")],roots:["roots"],symlinks:true,builtin_modules:true,allow_package_exports_in_directory_resolve:true,"#;
661667
assert_eq!(format!("{options}"), expected);
662668

663669
let options = ResolveOptions {

src/tests/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ mod roots;
2424
mod scoped_packages;
2525
mod simple;
2626
mod symlink;
27+
mod tsconfig_discovery;
2728
mod tsconfig_extends;
2829
mod tsconfig_paths;
2930
mod tsconfig_project_references;

src/tests/tsconfig_discovery.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
//! Tests for tsconfig discovery
2+
//!
3+
//! Tests that tsconfig.json can be auto-discovered when no explicit tsconfig option is provided.
4+
5+
#[test]
6+
fn tsconfig_discovery() {
7+
super::tsconfig_paths::tsconfig_resolve_impl(/* tsconfig_discovery */ true);
8+
}

0 commit comments

Comments
 (0)