Skip to content

Commit 0e1674a

Browse files
committed
feat: add tsconfig discovery (#758)
## Summary Add automatic tsconfig.json discovery that traverses up parent directories, similar to how package.json is discovered. closes #626 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Rolldown PR: rolldown/rolldown#6602 Oxlint PR: oxc-project/oxc#14721
1 parent 6b9a7cf commit 0e1674a

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)