diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index fd8a0c6e..ea25709f 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -35,7 +35,7 @@ jobs: env: RUSTFLAGS: "-C debuginfo=1 -C strip=none" - - uses: CodSpeedHQ/action@bb005fe1c1eea036d3894f02c049cb6b154a1c27 # v4.3.3 + - uses: CodSpeedHQ/action@6a8e2b874c338bf81cc5e8be715ada75908d3871 # v4.3.4 timeout-minutes: 30 with: run: cargo codspeed run diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45d032de..44783910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,7 +76,7 @@ jobs: components: clippy rust-docs - run: cargo clippy --all-features --all-targets -- -D warnings - run: RUSTDOCFLAGS='-D warnings' cargo doc --no-deps --all-features - - uses: crate-ci/typos@07d900b8fa1097806b8adb6391b0d3e0ac2fdea7 # v1.39.0 + - uses: crate-ci/typos@626c4bedb751ce0b7f03262ca97ddda9a076ae1c # v1.39.2 with: files: . diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 335cda0e..aec34df0 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -29,7 +29,7 @@ jobs: steps: - uses: taiki-e/checkout-action@b13d20b7cda4e2f325ef19895128f7ff735c0b3d # v1.3.1 - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + - uses: taiki-e/install-action@0be4756f42223b67aa4b7df5effad59010cbf4b9 # v2.62.51 with: tool: zizmor @@ -39,7 +39,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Upload SARIF file - uses: github/codeql-action/upload-sarif@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 + uses: github/codeql-action/upload-sarif@014f16e7ab1402f30e7c3329d33797e7948572db # v4.31.3 with: sarif_file: results.sarif category: zizmor diff --git a/Cargo.lock b/Cargo.lock index 1cee52dc..fc520936 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -799,6 +799,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.7.6" @@ -950,6 +959,7 @@ dependencies = [ "json-strip-comments", "once_cell", "papaya", + "parking_lot", "pico-args", "pnp", "rayon", @@ -992,6 +1002,29 @@ dependencies = [ "seize", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "pathdiff" version = "0.2.3" @@ -1200,6 +1233,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "seize" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 20128837..1facff86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,6 +82,7 @@ indexmap = { version = "2", features = ["serde"] } json-strip-comments = "3" once_cell = "1" # Use `std::sync::OnceLock::get_or_try_init` when it is stable. papaya = "0.2" +parking_lot = "0.12" rustc-hash = { version = "2" } serde = { version = "1", features = ["derive"] } # derive for Deserialize from package.json serde_json = { version = "1", features = ["preserve_order"] } # preserve_order: package_json.exports requires order such as `["require", "import", "default"]` diff --git a/benches/resolver.rs b/benches/resolver.rs index 20a529c1..f5aa12e5 100644 --- a/benches/resolver.rs +++ b/benches/resolver.rs @@ -183,6 +183,15 @@ fn bench_resolver_memory(c: &mut Criterion) { let mut group = c.benchmark_group("resolver_memory"); group.bench_with_input(BenchmarkId::from_parameter("single-thread"), &data, |b, data| { + let oxc_resolver = oxc_resolver_memory(); + b.iter(|| { + for (path, request) in data { + _ = oxc_resolver.resolve(path, request); + } + }); + }); + + group.bench_with_input(BenchmarkId::from_parameter("drop"), &data, |b, data| { b.iter(|| { let oxc_resolver = oxc_resolver_memory(); // Measure `Drop` performance. for (path, request) in data { @@ -192,8 +201,8 @@ fn bench_resolver_memory(c: &mut Criterion) { }); group.bench_with_input(BenchmarkId::from_parameter("multi-thread"), &data, |b, data| { + let oxc_resolver = oxc_resolver_memory(); b.iter(|| { - let oxc_resolver = oxc_resolver_memory(); // Measure `Drop` performance. data.par_iter().for_each(|(path, request)| { _ = oxc_resolver.resolve(path, request); }); @@ -204,8 +213,8 @@ fn bench_resolver_memory(c: &mut Criterion) { BenchmarkId::from_parameter("resolve from symlinks"), &symlinks_range, |b, data| { + let oxc_resolver = oxc_resolver_memory(); b.iter(|| { - let oxc_resolver = oxc_resolver_memory(); // Measure `Drop` performance. for i in data.clone() { assert!( oxc_resolver.resolve(&symlink_test_dir, &format!("./file{i}")).is_ok(), @@ -238,8 +247,8 @@ fn bench_resolver_real(c: &mut Criterion) { let mut group = c.benchmark_group("resolver_real"); group.bench_with_input(BenchmarkId::from_parameter("single-thread"), &data, |b, data| { + let oxc_resolver = oxc_resolver_real(); b.iter(|| { - let oxc_resolver = oxc_resolver_real(); // Measure `Drop` performance. for (path, request) in data { _ = oxc_resolver.resolve(path, request); } @@ -247,8 +256,8 @@ fn bench_resolver_real(c: &mut Criterion) { }); group.bench_with_input(BenchmarkId::from_parameter("multi-thread"), &data, |b, data| { + let oxc_resolver = oxc_resolver_real(); b.iter(|| { - let oxc_resolver = oxc_resolver_real(); // Measure `Drop` performance. data.par_iter().for_each(|(path, request)| { _ = oxc_resolver.resolve(path, request); }); @@ -259,8 +268,8 @@ fn bench_resolver_real(c: &mut Criterion) { BenchmarkId::from_parameter("resolve from symlinks"), &symlinks_range, |b, data| { + let oxc_resolver = oxc_resolver_real(); b.iter(|| { - let oxc_resolver = oxc_resolver_real(); // Measure `Drop` performance. for i in data.clone() { assert!( oxc_resolver.resolve(&symlink_test_dir, &format!("./file{i}")).is_ok(), @@ -707,5 +716,33 @@ mod memory_fs { )) }) } + + fn canonicalize(&self, path: &Path) -> io::Result { + // Follow symlinks to resolve the canonical path + let mut current = path.to_path_buf(); + let mut visited = FxHashSet::default(); + + while let Some(target) = self.symlinks.get(¤t) { + if !visited.insert(current.clone()) { + return Err(io::Error::other("Circular symlink")); + } + + current = if target.is_relative() { + current.parent().unwrap().join(target) + } else { + target.clone() + }; + } + + // Verify the final path exists + if self.files.contains_key(¤t) || self.directories.contains(¤t) { + Ok(current) + } else { + Err(io::Error::new( + io::ErrorKind::NotFound, + format!("Path not found: {}", path.display()), + )) + } + } } } diff --git a/examples/many.rs b/examples/many.rs new file mode 100644 index 00000000..420e120c --- /dev/null +++ b/examples/many.rs @@ -0,0 +1,66 @@ +use std::{env, fs}; + +use rayon::prelude::*; + +use oxc_resolver::{ResolveOptions, Resolver}; + +fn main() { + let cwd = env::current_dir().expect("Failed to get current directory"); + let node_modules = cwd.join("node_modules"); + + if !node_modules.exists() { + eprintln!("node_modules directory not found at {}", node_modules.display()); + return; + } + + // Collect all package names + let mut packages = Vec::new(); + + let entries = fs::read_dir(&node_modules).expect("Failed to read node_modules directory"); + + for entry in entries.filter_map(Result::ok) { + let path = entry.path(); + if !path.is_dir() { + continue; + } + + let dir_name = path.file_name().unwrap().to_string_lossy(); + + // Skip dot directories + if dir_name.starts_with('.') { + continue; + } + + if dir_name.starts_with('@') { + // Skip @types packages + if dir_name == "@types" { + continue; + } + // Scoped package - read subdirectories + if let Ok(scope_entries) = fs::read_dir(&path) { + for scope_entry in scope_entries.filter_map(Result::ok) { + let scope_path = scope_entry.path(); + if scope_path.is_dir() { + let package_name = scope_path.file_name().unwrap().to_string_lossy(); + packages.push(format!("{dir_name}/{package_name}")); + } + } + } + } else { + // Regular package + packages.push(dir_name.to_string()); + } + } + + let options = ResolveOptions { + condition_names: vec!["node".into(), "import".into()], + ..ResolveOptions::default() + }; + let resolver = Resolver::new(options); + + packages.par_iter().for_each(|package| { + if let Err(err) = resolver.resolve(&cwd, package) { + eprintln!("{package}: {err}"); + } + }); +} diff --git a/fixtures/symlink-with-nested-node_modules/.gitignore b/fixtures/symlink-with-nested-node_modules/.gitignore deleted file mode 100644 index cf4bab9d..00000000 --- a/fixtures/symlink-with-nested-node_modules/.gitignore +++ /dev/null @@ -1 +0,0 @@ -!node_modules diff --git a/fixtures/symlink-with-nested-node_modules/bar/node_modules/foo b/fixtures/symlink-with-nested-node_modules/bar/node_modules/foo deleted file mode 120000 index 99d688a4..00000000 --- a/fixtures/symlink-with-nested-node_modules/bar/node_modules/foo +++ /dev/null @@ -1 +0,0 @@ -../../foo/node_modules/foo \ No newline at end of file diff --git a/fixtures/symlink-with-nested-node_modules/foo/node_modules/dep/index.js b/fixtures/symlink-with-nested-node_modules/foo/node_modules/dep/index.js deleted file mode 100644 index e69de29b..00000000 diff --git a/fixtures/symlink-with-nested-node_modules/foo/node_modules/foo/index.js b/fixtures/symlink-with-nested-node_modules/foo/node_modules/foo/index.js deleted file mode 100644 index e69de29b..00000000 diff --git a/package.json b/package.json index c87b876a..e3e36cc8 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "11.13.2", "license": "MIT", "description": "Oxc Resolver Node API", - "packageManager": "pnpm@10.20.0", + "packageManager": "pnpm@10.22.0", "homepage": "https://oxc.rs", "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c148eabc..de302896 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,13 +10,13 @@ importers: devDependencies: '@napi-rs/cli': specifier: ^3.3.1 - version: 3.4.1(@emnapi/runtime@1.7.0)(@types/node@24.10.0) + version: 3.4.1(@emnapi/runtime@1.7.0)(@types/node@24.10.1) '@napi-rs/wasm-runtime': specifier: ^1.0.7 version: 1.0.7 '@types/node': specifier: ^24.9.1 - version: 24.10.0 + version: 24.10.1 emnapi: specifier: ^1.6.0 version: 1.7.0 @@ -25,7 +25,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.0 - version: 4.0.8(@types/node@24.10.0) + version: 4.0.8(@types/node@24.10.1) fixtures/pnpm: devDependencies: @@ -905,8 +905,8 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/node@24.10.0': - resolution: {integrity: sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==} + '@types/node@24.10.1': + resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} '@types/stylis@4.2.5': resolution: {integrity: sha512-1Xve+NMN7FWjY14vLoY5tL3BVEQ/n42YLwaqJIPYhotZ9uBHt87VceMwWQpzmdEt2TNXIorIFG+YeCUUW7RInw==} @@ -1541,134 +1541,134 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.1(@types/node@24.10.0)': + '@inquirer/checkbox@4.3.1(@types/node@24.10.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.1(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/type': 3.0.10(@types/node@24.10.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/confirm@5.1.20(@types/node@24.10.0)': + '@inquirer/confirm@5.1.20(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/core@10.3.1(@types/node@24.10.0)': + '@inquirer/core@10.3.1(@types/node@24.10.1)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/type': 3.0.10(@types/node@24.10.1) cli-width: 4.1.0 mute-stream: 3.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/editor@4.2.22(@types/node@24.10.0)': + '@inquirer/editor@4.2.22(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/external-editor': 1.0.3(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/external-editor': 1.0.3(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/expand@4.0.22(@types/node@24.10.0)': + '@inquirer/expand@4.0.22(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/external-editor@1.0.3(@types/node@24.10.0)': + '@inquirer/external-editor@1.0.3(@types/node@24.10.1)': dependencies: chardet: 2.1.1 iconv-lite: 0.7.0 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.0(@types/node@24.10.0)': + '@inquirer/input@4.3.0(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/number@3.0.22(@types/node@24.10.0)': + '@inquirer/number@3.0.22(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/password@4.0.22(@types/node@24.10.0)': + '@inquirer/password@4.0.22(@types/node@24.10.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 - - '@inquirer/prompts@7.10.0(@types/node@24.10.0)': - dependencies: - '@inquirer/checkbox': 4.3.1(@types/node@24.10.0) - '@inquirer/confirm': 5.1.20(@types/node@24.10.0) - '@inquirer/editor': 4.2.22(@types/node@24.10.0) - '@inquirer/expand': 4.0.22(@types/node@24.10.0) - '@inquirer/input': 4.3.0(@types/node@24.10.0) - '@inquirer/number': 3.0.22(@types/node@24.10.0) - '@inquirer/password': 4.0.22(@types/node@24.10.0) - '@inquirer/rawlist': 4.1.10(@types/node@24.10.0) - '@inquirer/search': 3.2.1(@types/node@24.10.0) - '@inquirer/select': 4.4.1(@types/node@24.10.0) + '@types/node': 24.10.1 + + '@inquirer/prompts@7.10.0(@types/node@24.10.1)': + dependencies: + '@inquirer/checkbox': 4.3.1(@types/node@24.10.1) + '@inquirer/confirm': 5.1.20(@types/node@24.10.1) + '@inquirer/editor': 4.2.22(@types/node@24.10.1) + '@inquirer/expand': 4.0.22(@types/node@24.10.1) + '@inquirer/input': 4.3.0(@types/node@24.10.1) + '@inquirer/number': 3.0.22(@types/node@24.10.1) + '@inquirer/password': 4.0.22(@types/node@24.10.1) + '@inquirer/rawlist': 4.1.10(@types/node@24.10.1) + '@inquirer/search': 3.2.1(@types/node@24.10.1) + '@inquirer/select': 4.4.1(@types/node@24.10.1) optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/rawlist@4.1.10(@types/node@24.10.0)': + '@inquirer/rawlist@4.1.10(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) + '@inquirer/type': 3.0.10(@types/node@24.10.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/search@3.2.1(@types/node@24.10.0)': + '@inquirer/search@3.2.1(@types/node@24.10.1)': dependencies: - '@inquirer/core': 10.3.1(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/type': 3.0.10(@types/node@24.10.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/select@4.4.1(@types/node@24.10.0)': + '@inquirer/select@4.4.1(@types/node@24.10.1)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.1(@types/node@24.10.0) + '@inquirer/core': 10.3.1(@types/node@24.10.1) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.10.0) + '@inquirer/type': 3.0.10(@types/node@24.10.1) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 - '@inquirer/type@3.0.10(@types/node@24.10.0)': + '@inquirer/type@3.0.10(@types/node@24.10.1)': optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 '@jridgewell/sourcemap-codec@1.5.5': {} - '@napi-rs/cli@3.4.1(@emnapi/runtime@1.7.0)(@types/node@24.10.0)': + '@napi-rs/cli@3.4.1(@emnapi/runtime@1.7.0)(@types/node@24.10.1)': dependencies: - '@inquirer/prompts': 7.10.0(@types/node@24.10.0) + '@inquirer/prompts': 7.10.0(@types/node@24.10.1) '@napi-rs/cross-toolchain': 1.0.3 '@napi-rs/wasm-tools': 1.0.1 '@octokit/rest': 22.0.1 @@ -2055,7 +2055,7 @@ snapshots: '@types/estree@1.0.8': {} - '@types/node@24.10.0': + '@types/node@24.10.1': dependencies: undici-types: 7.16.0 @@ -2070,13 +2070,13 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.8(vite@7.2.2(@types/node@24.10.0))': + '@vitest/mocker@4.0.8(vite@7.2.2(@types/node@24.10.1))': dependencies: '@vitest/spy': 4.0.8 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.2(@types/node@24.10.0) + vite: 7.2.2(@types/node@24.10.1) '@vitest/pretty-format@4.0.8': dependencies: @@ -2489,7 +2489,7 @@ snapshots: universal-user-agent@7.0.3: {} - vite@7.2.2(@types/node@24.10.0): + vite@7.2.2(@types/node@24.10.1): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -2498,13 +2498,13 @@ snapshots: rollup: 4.53.1 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 fsevents: 2.3.3 - vitest@4.0.8(@types/node@24.10.0): + vitest@4.0.8(@types/node@24.10.1): dependencies: '@vitest/expect': 4.0.8 - '@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@24.10.0)) + '@vitest/mocker': 4.0.8(vite@7.2.2(@types/node@24.10.1)) '@vitest/pretty-format': 4.0.8 '@vitest/runner': 4.0.8 '@vitest/snapshot': 4.0.8 @@ -2521,10 +2521,10 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.2(@types/node@24.10.0) + vite: 7.2.2(@types/node@24.10.1) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 24.10.0 + '@types/node': 24.10.1 transitivePeerDependencies: - jiti - less diff --git a/src/cache/cache_impl.rs b/src/cache/cache_impl.rs index 9a45117b..ad2e35d5 100644 --- a/src/cache/cache_impl.rs +++ b/src/cache/cache_impl.rs @@ -1,31 +1,35 @@ use std::{ borrow::Cow, + collections::HashSet as StdHashSet, hash::{BuildHasherDefault, Hash, Hasher}, io, path::{Path, PathBuf}, - sync::{Arc, atomic::Ordering}, + sync::Arc, }; use cfg_if::cfg_if; #[cfg(feature = "yarn_pnp")] use once_cell::sync::OnceCell; use papaya::{HashMap, HashSet}; +use parking_lot::RwLock; use rustc_hash::FxHasher; use super::borrowed_path::BorrowedCachedPath; use super::cached_path::{CachedPath, CachedPathImpl}; use super::hasher::IdentityHasher; -use super::thread_local::THREAD_ID; use crate::{ FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig, context::ResolveContext as Ctx, path::PathUtil, }; +pub type PackageJsonIndex = usize; + /// Cache implementation used for caching filesystem access. #[derive(Default)] pub struct Cache { pub(crate) fs: Fs, pub(crate) paths: HashSet>, + pub(crate) package_jsons: RwLock>>, pub(crate) tsconfigs: HashMap, BuildHasherDefault>, #[cfg(feature = "yarn_pnp")] pub(crate) yarn_pnp_manifest: OnceCell, @@ -35,6 +39,7 @@ impl Cache { pub fn clear(&self) { self.paths.pin().clear(); self.tsconfigs.pin().clear(); + self.package_jsons.write().clear(); } #[allow(clippy::cast_possible_truncation)] @@ -79,9 +84,9 @@ impl Cache { } pub(crate) fn is_file(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { - if let Some(meta) = path.meta(&self.fs) { + if path.is_file(&self.fs).is_some_and(|b| b) { ctx.add_file_dependency(path.path()); - meta.is_file + true } else { ctx.add_missing_dependency(path.path()); false @@ -89,58 +94,117 @@ impl Cache { } pub(crate) fn is_dir(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { - path.meta(&self.fs).map_or_else( + path.is_dir(&self.fs).map_or_else( || { ctx.add_missing_dependency(path.path()); false }, - |meta| meta.is_dir, + |b| b, ) } + /// Get package.json of a path of `path`. + /// + /// # Errors + /// + /// * [ResolveError::Json] pub(crate) fn get_package_json( &self, path: &CachedPath, options: &ResolveOptions, ctx: &mut Ctx, ) -> Result>, ResolveError> { + self.find_package_json(path, options, ctx).map(|option_package_json| { + option_package_json.filter(|package_json| { + package_json + .path() + .parent() + .is_some_and(|p| p.as_os_str() == path.path().as_os_str()) + }) + }) + } + + /// Find package.json of a path by traversing parent directories. + /// + /// # Errors + /// + /// * [ResolveError::Json] + pub(crate) fn find_package_json( + &self, + path: &CachedPath, + options: &ResolveOptions, + ctx: &mut Ctx, + ) -> Result>, ResolveError> { + let mut path = path.clone(); + // Go up directories when the querying path is not a directory + while !self.is_dir(&path, ctx) { + if let Some(cv) = path.parent() { + path = cv; + } else { + break; + } + } + self.find_package_json_impl(&path, options, ctx).map(|option_index| { + option_index.and_then(|index| self.package_jsons.read().get(index).cloned()) + }) + } + + /// Find package.json of a path by traversing parent directories. + /// + /// # Errors + /// + /// * [ResolveError::Json] + fn find_package_json_impl( + &self, + path: &CachedPath, + options: &ResolveOptions, + ctx: &mut Ctx, + ) -> Result, ResolveError> { // Change to `std::sync::OnceLock::get_or_try_init` when it is stable. - let result = path - .package_json + path.package_json .get_or_try_init(|| { let package_json_path = path.path.join("package.json"); let Ok(package_json_bytes) = self.fs.read(&package_json_path) else { - return Ok(None); + if let Some(deps) = &mut ctx.missing_dependencies { + deps.push(package_json_path); + } + return path.parent().map_or(Ok(None), |parent| { + self.find_package_json_impl(&parent, options, ctx) + }); }; - let real_path = if options.symlinks { self.canonicalize(path)?.join("package.json") } else { package_json_path.clone() }; - PackageJson::parse(&self.fs, package_json_path, real_path, package_json_bytes) - .map(|package_json| Some(Arc::new(package_json))) - .map_err(ResolveError::Json) + PackageJson::parse( + &self.fs, + package_json_path.clone(), + real_path, + package_json_bytes, + ) + .map(|package_json| { + let arc = Arc::new(package_json); + let index = { + let mut arena = self.package_jsons.write(); + let index = arena.len(); + arena.push(arc); + index + }; + Some(index) + }) + .map_err(ResolveError::Json) + // https://github.com/webpack/enhanced-resolve/blob/58464fc7cb56673c9aa849e68e6300239601e615/lib/DescriptionFileUtils.js#L68-L82 + .inspect(|_| { + ctx.add_file_dependency(&package_json_path); + }) + .inspect_err(|_| { + if let Some(deps) = &mut ctx.file_dependencies { + deps.push(package_json_path.clone()); + } + }) }) - .cloned(); - // https://github.com/webpack/enhanced-resolve/blob/58464fc7cb56673c9aa849e68e6300239601e615/lib/DescriptionFileUtils.js#L68-L82 - match &result { - Ok(Some(package_json)) => { - ctx.add_file_dependency(&package_json.path); - } - Ok(None) => { - // Avoid an allocation by making this lazy - if let Some(deps) = &mut ctx.missing_dependencies { - deps.push(path.path.join("package.json")); - } - } - Err(_) => { - if let Some(deps) = &mut ctx.file_dependencies { - deps.push(path.path.join("package.json")); - } - } - } - result + .cloned() } pub(crate) fn get_tsconfig Result<(), ResolveError>>( @@ -212,6 +276,7 @@ impl Cache { .hasher(BuildHasherDefault::default()) .resize_mode(papaya::ResizeMode::Blocking) .build(), + package_jsons: RwLock::new(Vec::with_capacity(512)), tsconfigs: HashMap::builder() .hasher(BuildHasherDefault::default()) .resize_mode(papaya::ResizeMode::Blocking) @@ -225,36 +290,59 @@ impl Cache { /// /// pub(crate) fn canonicalize_impl(&self, path: &CachedPath) -> Result { - // Check if this thread is already canonicalizing. If so, we have found a circular symlink. - // If a different thread is canonicalizing, OnceLock will queue this thread to wait for the result. - let tid = THREAD_ID.with(|t| *t); - if path.canonicalizing.load(Ordering::Acquire) == tid { - return Err(io::Error::new(io::ErrorKind::NotFound, "Circular symlink").into()); - } + // Each canonicalization chain gets its own visited set for circular symlink detection + let mut visited = StdHashSet::with_hasher(BuildHasherDefault::::default()); - let mut canonicalized_guard = path.canonicalized.lock().unwrap(); - let canonicalized = canonicalized_guard.clone()?; - if let Some(cached_path) = canonicalized.upgrade() { - return Ok(CachedPath(cached_path)); + // canonicalize_with_visited now handles caching at every recursion level + self.canonicalize_with_visited(path, &mut visited).or_else(|err| { + // Fallback: if canonicalization fails and path's cache was cleared, + // try direct FS canonicalize without caching the result + self.fs + .canonicalize(path.path()) + .map(|canonical| self.value(&canonical)) + .map_err(|_| err) + }) + } + + /// Internal helper for canonicalization with circular symlink detection. + fn canonicalize_with_visited( + &self, + path: &CachedPath, + visited: &mut StdHashSet>, + ) -> Result { + // Check cache first - if this path was already canonicalized, return the cached result + if let Some(weak) = path.canonicalized.get() { + return weak.upgrade().map(CachedPath).ok_or_else(|| { + io::Error::new(io::ErrorKind::NotFound, "Cached path no longer exists").into() + }); } - path.canonicalizing.store(tid, Ordering::Release); + // Check for circular symlink by tracking visited paths in the current canonicalization chain + if !visited.insert(path.hash) { + return Err(io::Error::new(io::ErrorKind::NotFound, "Circular symlink").into()); + } let res = path.parent().map_or_else( || Ok(path.normalize_root(self)), |parent| { - self.canonicalize_impl(&parent).and_then(|parent_canonical| { + self.canonicalize_with_visited(&parent, visited).and_then(|parent_canonical| { let normalized = parent_canonical .normalize_with(path.path().strip_prefix(parent.path()).unwrap(), self); if self.fs.symlink_metadata(path.path()).is_ok_and(|m| m.is_symlink) { let link = self.fs.read_link(normalized.path())?; if link.is_absolute() { - return self.canonicalize_impl(&self.value(&link.normalize())); + return self.canonicalize_with_visited( + &self.value(&link.normalize()), + visited, + ); } else if let Some(dir) = normalized.parent() { // Symlink is relative `../../foo.js`, use the path directory // to resolve this symlink. - return self.canonicalize_impl(&dir.normalize_with(&link, self)); + return self.canonicalize_with_visited( + &dir.normalize_with(&link, self), + visited, + ); } debug_assert!( false, @@ -266,12 +354,14 @@ impl Cache { Ok(normalized) }) }, - ); + )?; - path.canonicalizing.store(0, Ordering::Release); - // Convert to Weak reference for storage - *canonicalized_guard = res.as_ref().map_err(Clone::clone).map(|cp| Arc::downgrade(&cp.0)); + // Cache the result before removing from visited set + // This ensures parent canonicalization results are cached and reused + let _ = path.canonicalized.set(Arc::downgrade(&res.0)); - res + // Remove from visited set when unwinding the recursion + visited.remove(&path.hash); + Ok(res) } } diff --git a/src/cache/cached_path.rs b/src/cache/cached_path.rs index af676545..430101d1 100644 --- a/src/cache/cached_path.rs +++ b/src/cache/cached_path.rs @@ -4,18 +4,16 @@ use std::{ hash::{Hash, Hasher}, ops::Deref, path::{Component, Path, PathBuf}, - sync::{Arc, Mutex, Weak, atomic::AtomicU64}, + sync::{Arc, Weak}, }; use cfg_if::cfg_if; use once_cell::sync::OnceCell as OnceLock; use super::cache_impl::Cache; +use super::cache_impl::PackageJsonIndex; use super::thread_local::SCRATCH_PATH; -use crate::{ - FileMetadata, FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig, - context::ResolveContext as Ctx, -}; +use crate::{FileSystem, TsConfig, context::ResolveContext as Ctx}; #[derive(Clone)] pub struct CachedPath(pub Arc); @@ -26,11 +24,10 @@ pub struct CachedPathImpl { pub parent: Option>, pub is_node_modules: bool, pub inside_node_modules: bool, - pub meta: OnceLock>, - pub canonicalized: Mutex, ResolveError>>, - pub canonicalizing: AtomicU64, + pub meta: OnceLock>, // None means not found. + pub canonicalized: OnceLock>, pub node_modules: OnceLock>>, - pub package_json: OnceLock>>, + pub package_json: OnceLock>, pub tsconfig: OnceLock>>, } @@ -49,8 +46,7 @@ impl CachedPathImpl { is_node_modules, inside_node_modules, meta: OnceLock::new(), - canonicalized: Mutex::new(Ok(Weak::new())), - canonicalizing: AtomicU64::new(0), + canonicalized: OnceLock::new(), node_modules: OnceLock::new(), package_json: OnceLock::new(), tsconfig: OnceLock::new(), @@ -110,36 +106,6 @@ impl CachedPath { .and_then(|weak| weak.upgrade().map(CachedPath)) } - /// Find package.json of a path by traversing parent directories. - /// - /// # Errors - /// - /// * [ResolveError::Json] - pub(crate) fn find_package_json( - &self, - options: &ResolveOptions, - cache: &Cache, - ctx: &mut Ctx, - ) -> Result>, ResolveError> { - let mut cache_value = self.clone(); - // Go up directories when the querying path is not a directory - while !cache.is_dir(&cache_value, ctx) { - if let Some(cv) = cache_value.parent() { - cache_value = cv; - } else { - break; - } - } - let mut cache_value = Some(cache_value); - while let Some(cv) = cache_value { - if let Some(package_json) = cache.get_package_json(&cv, options, ctx)? { - return Ok(Some(package_json)); - } - cache_value = cv.parent(); - } - Ok(None) - } - pub(crate) fn add_extension(&self, ext: &str, cache: &Cache) -> Self { SCRATCH_PATH.with_borrow_mut(|path| { path.clear(); @@ -228,8 +194,16 @@ impl CachedPath { } impl CachedPath { - pub(crate) fn meta(&self, fs: &Fs) -> Option { - *self.meta.get_or_init(|| fs.metadata(&self.path).ok()) + fn metadata(&self, fs: &Fs) -> Option<(bool, bool)> { + *self.meta.get_or_init(|| fs.metadata(&self.path).ok().map(|r| (r.is_file, r.is_dir))) + } + + pub(crate) fn is_file(&self, fs: &Fs) -> Option { + self.metadata(fs).map(|r| r.0) + } + + pub(crate) fn is_dir(&self, fs: &Fs) -> Option { + self.metadata(fs).map(|r| r.1) } } diff --git a/src/cache/thread_local.rs b/src/cache/thread_local.rs index 7c8c7c9d..70607845 100644 --- a/src/cache/thread_local.rs +++ b/src/cache/thread_local.rs @@ -1,14 +1,7 @@ -use std::{ - cell::RefCell, - path::PathBuf, - sync::atomic::{AtomicU64, Ordering}, -}; - -static THREAD_COUNT: AtomicU64 = AtomicU64::new(1); +use std::{cell::RefCell, path::PathBuf}; thread_local! { /// Per-thread pre-allocated path that is used to perform operations on paths more quickly. /// Learned from parcel pub static SCRATCH_PATH: RefCell = RefCell::new(PathBuf::with_capacity(256)); - pub static THREAD_ID: u64 = THREAD_COUNT.fetch_add(1, Ordering::SeqCst); } diff --git a/src/file_system.rs b/src/file_system.rs index 0fde005b..44043a59 100644 --- a/src/file_system.rs +++ b/src/file_system.rs @@ -64,6 +64,13 @@ pub trait FileSystem: Send + Sync { /// /// See [std::fs::read_link] fn read_link(&self, path: &Path) -> Result; + + /// Returns the canonical, absolute form of a path with all intermediate components normalized. + /// + /// # Errors + /// + /// See [std::fs::canonicalize] + fn canonicalize(&self, path: &Path) -> io::Result; } /// Metadata information about a file @@ -208,6 +215,14 @@ impl FileSystemOs { } } } + + /// # Errors + /// + /// See [std::fs::canonicalize] + #[inline] + pub fn canonicalize(path: &Path) -> io::Result { + fs::canonicalize(path) + } } impl FileSystem for FileSystemOs { @@ -281,6 +296,21 @@ impl FileSystem for FileSystemOs { } Self::read_link(path) } + + fn canonicalize(&self, path: &Path) -> io::Result { + cfg_if! { + if #[cfg(feature = "yarn_pnp")] { + if self.yarn_pnp { + return match VPath::from(path)? { + VPath::Zip(info) => Self::canonicalize(&info.physical_base_path().join(info.zip_path)), + VPath::Virtual(info) => Self::canonicalize(&info.physical_base_path()), + VPath::Native(path) => Self::canonicalize(&path), + } + } + } + } + Self::canonicalize(path) + } } #[test] diff --git a/src/lib.rs b/src/lib.rs index 1d23dd52..1589aec9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,7 +57,7 @@ mod path; mod resolution; mod specifier; mod tsconfig; -mod tsconfig_context; +mod tsconfig_resolver; #[cfg(target_os = "windows")] mod windows; @@ -83,11 +83,7 @@ pub use crate::{ CompilerOptions, CompilerOptionsPathsMap, ExtendsField, ProjectReference, TsConfig, }, }; -use crate::{ - context::ResolveContext as Ctx, path::SLASH_START, specifier::Specifier, - tsconfig_context::TsconfigResolveContext, -}; -use rustc_hash::FxHashSet; + use std::{ borrow::Cow, cmp::Ordering, @@ -97,6 +93,10 @@ use std::{ sync::Arc, }; +use rustc_hash::FxHashSet; + +use crate::{context::ResolveContext as Ctx, path::SLASH_START, specifier::Specifier}; + type ResolveResult = Result, ResolveError>; /// Context returned from the [Resolver::resolve_with_context] API @@ -187,27 +187,6 @@ impl ResolverGeneric { self.resolve_tracing(directory.as_ref(), specifier, &mut ctx) } - /// Resolve `tsconfig`. - /// - /// The path can be: - /// - /// * Path to a file with `.json` extension. - /// * Path to a file without `.json` extension, `.json` will be appended to filename. - /// * Path to a directory, where the filename is defaulted to `tsconfig.json` - /// - /// # Errors - /// - /// * See [ResolveError] - pub fn resolve_tsconfig>(&self, path: P) -> Result, ResolveError> { - let path = path.as_ref(); - self.load_tsconfig( - true, - path, - &TsconfigReferences::Auto, - &mut TsconfigResolveContext::default(), - ) - } - /// Resolve `specifier` at absolute `path` with [ResolveContext] /// /// # Errors @@ -260,20 +239,9 @@ impl ResolverGeneric { ) -> Result { ctx.with_fully_specified(self.options.fully_specified); - let cached_path = if self.options.symlinks { - self.load_realpath(&self.cache.value(path))? - } else { - path.to_path_buf() - }; - - let cached_path = self.cache.value(&cached_path); + let cached_path = self.cache.value(path); let cached_path = self.require(&cached_path, specifier, ctx)?; - - let path = if self.options.symlinks { - self.load_realpath(&cached_path)? - } else { - cached_path.to_path_buf() - }; + let path = self.load_realpath(&cached_path)?; let package_json = self.find_package_json_for_a_package(&cached_path, ctx)?; if let Some(package_json) = &package_json { @@ -315,7 +283,7 @@ impl ResolverGeneric { } Ok(last) } else { - cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx) + self.cache.find_package_json(cached_path, &self.options, ctx) } } @@ -605,8 +573,7 @@ impl ResolverGeneric { ) -> ResolveResult { // 1. Find the closest package scope SCOPE to DIR. // 2. If no scope was found, return. - let Some(package_json) = - cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)? + let Some(package_json) = self.cache.find_package_json(cached_path, &self.options, ctx)? else { return Ok(None); }; @@ -767,7 +734,7 @@ impl ResolverGeneric { for main_file in &self.options.main_files { let cached_path = cached_path.normalize_with(main_file, self.cache.as_ref()); if self.options.enforce_extension.is_disabled() - && let Some(path) = self.load_alias_or_file(&cached_path, ctx)? + && let Some(path) = self.load_browser_field_or_alias(&cached_path, ctx)? && self.check_restrictions(path.path()) { return Ok(Some(path)); @@ -789,7 +756,7 @@ impl ResolverGeneric { ) -> ResolveResult { if !self.options.alias_fields.is_empty() && let Some(package_json) = - cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)? + self.cache.find_package_json(cached_path, &self.options, ctx)? && let Some(path) = self.load_browser_field(cached_path, None, &package_json, ctx)? { return Ok(Some(path)); @@ -882,19 +849,11 @@ impl ResolverGeneric { let cached_path = cached_path.normalize_with(specifier, self.cache.as_ref()); - // Perf: try the directory first for package specifiers. if self.options.resolve_to_context { return Ok(self.cache.is_dir(&cached_path, ctx).then(|| cached_path.clone())); } - // `is_file` could be false because no extensions are considered yet, - // so we need to try `load_as_file` first when `specifier` does not end with a slash which indicates a dir instead. - if !specifier.ends_with('/') - && let Some(path) = self.load_as_file(&cached_path, ctx)? - { - return Ok(Some(path)); - } - + // Perf: try LOAD_AS_DIRECTORY first. No modern package manager creates `node_modules/X.js`. if self.cache.is_dir(&cached_path, ctx) { if let Some(path) = self.load_browser_field_or_alias(&cached_path, ctx)? { return Ok(Some(path)); @@ -902,9 +861,7 @@ impl ResolverGeneric { if let Some(path) = self.load_as_directory(&cached_path, ctx)? { return Ok(Some(path)); } - } - - if let Some(path) = self.load_as_directory(&cached_path, ctx)? { + } else if let Some(path) = self.load_as_file(&cached_path, ctx)? { return Ok(Some(path)); } } @@ -1051,8 +1008,7 @@ impl ResolverGeneric { ) -> ResolveResult { // 1. Find the closest package scope SCOPE to DIR. // 2. If no scope was found, return. - let Some(package_json) = - cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)? + let Some(package_json) = self.cache.find_package_json(cached_path, &self.options, ctx)? else { return Ok(None); }; @@ -1354,206 +1310,6 @@ impl ResolverGeneric { None } - fn load_tsconfig( - &self, - root: bool, - path: &Path, - references: &TsconfigReferences, - ctx: &mut TsconfigResolveContext, - ) -> Result, ResolveError> { - self.cache.get_tsconfig(root, path, |tsconfig| { - let directory = self.cache.value(tsconfig.directory()); - tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig"); - - if ctx.is_already_extended(tsconfig.path()) { - return Err(ResolveError::TsconfigCircularExtend( - ctx.get_extended_configs_with(tsconfig.path().to_path_buf()).into(), - )); - } - - // Extend tsconfig - let extended_tsconfig_paths = tsconfig - .extends() - .map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier)) - .collect::, _>>()?; - if !extended_tsconfig_paths.is_empty() { - ctx.with_extended_file(tsconfig.path().to_owned(), |ctx| { - for extended_tsconfig_path in extended_tsconfig_paths { - let extended_tsconfig = self.load_tsconfig( - /* root */ false, - &extended_tsconfig_path, - &TsconfigReferences::Disabled, - ctx, - )?; - tsconfig.extend_tsconfig(&extended_tsconfig); - } - Result::Ok::<(), ResolveError>(()) - })?; - } - - if tsconfig.load_references(references) { - let path = tsconfig.path().to_path_buf(); - let directory = tsconfig.directory().to_path_buf(); - for reference in tsconfig.references_mut() { - let reference_tsconfig_path = directory.normalize_with(reference.path()); - let 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(()) - }, - )?; - reference.set_tsconfig(tsconfig); - } - } - 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::, _>>()?; - for extended_tsconfig_path in extended_tsconfig_paths { - let extended_tsconfig = self.load_tsconfig( - /* root */ false, - &extended_tsconfig_path, - &TsconfigReferences::Disabled, - ctx, - )?; - tsconfig.extend_tsconfig(&extended_tsconfig); - } - Ok(()) - } - - fn load_tsconfig_paths( - &self, - cached_path: &CachedPath, - specifier: &str, - ctx: &mut Ctx, - ) -> ResolveResult { - if cached_path.inside_node_modules() { - return Ok(None); - } - let tsconfig = match &self.options.tsconfig { - None => return Ok(None), - Some(TsconfigDiscovery::Manual(tsconfig_options)) => { - let tsconfig = self.load_tsconfig( - /* root */ true, - &tsconfig_options.config_file, - &tsconfig_options.references, - &mut TsconfigResolveContext::default(), - )?; - // Cache the loaded tsconfig in the path's directory - let tsconfig_dir = self.cache.value(tsconfig.directory()); - _ = tsconfig_dir.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig))); - tsconfig - } - Some(TsconfigDiscovery::Auto) => { - let Some(tsconfig) = self.find_tsconfig(cached_path, ctx)? else { - return Ok(None); - }; - tsconfig - } - }; - - let paths = tsconfig.resolve(cached_path.path(), specifier); - for path in paths { - let resolved_path = self.cache.value(&path); - if let Some(resolution) = self.load_as_file_or_directory(&resolved_path, ".", ctx)? { - // Cache the tsconfig in the resolved path - _ = resolved_path.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig))); - return Ok(Some(resolution)); - } - } - Ok(None) - } - - /// Find tsconfig.json of a path by traversing parent directories. - /// - /// # Errors - /// - /// * [ResolveError::Json] - pub(crate) fn find_tsconfig( - &self, - cached_path: &CachedPath, - ctx: &mut Ctx, - ) -> Result>, ResolveError> { - // Don't discover tsconfig for paths inside node_modules - if cached_path.inside_node_modules() { - return Ok(None); - } - // Skip non-absolute paths (e.g. virtual modules) - if !cached_path.path.is_absolute() { - return Ok(None); - } - - let mut cache_value = Some(cached_path.clone()); - while let Some(cv) = cache_value { - if let Some(tsconfig) = cv.tsconfig.get_or_try_init(|| { - let tsconfig_path = cv.path.join("tsconfig.json"); - let tsconfig_path = self.cache.value(&tsconfig_path); - if self.cache.is_file(&tsconfig_path, ctx) { - self.resolve_tsconfig(tsconfig_path.path()).map(Some) - } else { - Ok(None) - } - })? { - return Ok(Some(Arc::clone(tsconfig))); - } - cache_value = cv.parent(); - } - Ok(None) - } - - fn get_extended_tsconfig_path( - &self, - directory: &CachedPath, - tsconfig: &TsConfig, - specifier: &str, - ) -> Result { - match specifier.as_bytes().first() { - None => Err(ResolveError::Specifier(SpecifierError::Empty(specifier.to_string()))), - Some(b'/') => Ok(PathBuf::from(specifier)), - Some(b'.') => Ok(tsconfig.directory().normalize_with(specifier)), - _ => self - .clone_with_options(ResolveOptions { - tsconfig: None, - extensions: vec![".json".into()], - main_files: vec!["tsconfig.json".into()], - #[cfg(feature = "yarn_pnp")] - yarn_pnp: self.options.yarn_pnp, - #[cfg(feature = "yarn_pnp")] - cwd: self.options.cwd.clone(), - ..ResolveOptions::default() - }) - .load_package_self_or_node_modules(directory, specifier, &mut Ctx::default()) - .map(|p| p.to_path_buf()) - .map_err(|err| match err { - ResolveError::NotFound(_) => { - ResolveError::TsconfigNotFound(PathBuf::from(specifier)) - } - _ => err, - }), - } - } - /// PACKAGE_RESOLVE(packageSpecifier, parentURL) fn package_resolve( &self, @@ -2125,8 +1881,7 @@ impl ResolverGeneric { Some("js" | "ts") => { // 7. Let packageURL be the result of LOOKUP_PACKAGE_SCOPE(url). // 8. Let pjson be the result of READ_PACKAGE_JSON(packageURL). - let package_json = - cached_path.find_package_json(&self.options, self.cache.as_ref(), ctx)?; + let package_json = self.cache.find_package_json(cached_path, &self.options, ctx)?; // 9. Let packageType be null. if let Some(package_json) = package_json { // 10. If pjson?.type is "module" or "commonjs", then diff --git a/src/package_json/mod.rs b/src/package_json/mod.rs index 9377b84d..4c867420 100644 --- a/src/package_json/mod.rs +++ b/src/package_json/mod.rs @@ -14,15 +14,20 @@ pub use serde::*; #[cfg(target_endian = "little")] pub use simd::*; -use std::{fmt, path::PathBuf}; +use std::{fmt, path::Path}; use crate::JSONError; /// Check if JSON content is empty or contains only whitespace -fn check_if_empty(json_bytes: &[u8], path: PathBuf) -> Result<(), JSONError> { +fn check_if_empty(json_bytes: &[u8], path: &Path) -> Result<(), JSONError> { // Check if content is empty or whitespace-only if json_bytes.iter().all(|&b| b.is_ascii_whitespace()) { - return Err(JSONError { path, message: "File is empty".to_string(), line: 0, column: 0 }); + return Err(JSONError { + path: path.to_path_buf(), + message: "File is empty".to_string(), + line: 0, + column: 0, + }); } Ok(()) } diff --git a/src/package_json/serde.rs b/src/package_json/serde.rs index 17f87cd5..73291c89 100644 --- a/src/package_json/serde.rs +++ b/src/package_json/serde.rs @@ -230,7 +230,7 @@ impl PackageJson { let json_bytes = if json.starts_with(b"\xEF\xBB\xBF") { &json[3..] } else { &json[..] }; // Check if empty after BOM stripping - super::check_if_empty(json_bytes, path.clone())?; + super::check_if_empty(json_bytes, &path)?; // Parse JSON directly from bytes let value = serde_json::from_slice::(json_bytes).map_err(|error| JSONError { diff --git a/src/package_json/simd.rs b/src/package_json/simd.rs index 92b8c7da..373c643d 100644 --- a/src/package_json/simd.rs +++ b/src/package_json/simd.rs @@ -269,7 +269,7 @@ impl PackageJson { } // Check if empty after BOM stripping - super::check_if_empty(&json_bytes, path.clone())?; + super::check_if_empty(&json_bytes, &path)?; // Create the self-cell with the JSON bytes and parsed BorrowedValue let cell = PackageJsonCell::try_new(MutBorrow::new(json_bytes), |bytes| { diff --git a/src/tests/alias.rs b/src/tests/alias.rs index 6d0c37a1..c7fda5b6 100644 --- a/src/tests/alias.rs +++ b/src/tests/alias.rs @@ -18,18 +18,18 @@ fn alias() { let f = Path::new("/"); let file_system = MemoryFS::new(&[ - ("/a/index", ""), - ("/a/dir/index", ""), - ("/recursive/index", ""), - ("/recursive/dir/index", ""), - ("/b/index", ""), - ("/b/dir/index", ""), - ("/c/index", ""), - ("/c/dir/index", ""), - ("/d/index.js", ""), + ("/a/index.js", ""), + ("/a/dir/index.js", ""), + ("/recursive/index.js", ""), + ("/recursive/dir/index.js", ""), + ("/b/index.js", ""), + ("/b/dir/index.js", ""), + ("/c/index.js", ""), + ("/c/dir/index.js", ""), + ("/d/index.js.js", ""), ("/d/dir/.empty", ""), - ("/e/index", ""), - ("/e/anotherDir/index", ""), + ("/e/index.js", ""), + ("/e/anotherDir/index.js", ""), ("/e/dir/file", ""), ("/dashed-name", ""), ]); @@ -39,8 +39,8 @@ fn alias() { ResolveOptions { alias: vec![ ("aliasA".into(), vec![AliasValue::from("a")]), - ("b$".into(), vec![AliasValue::from("a/index")]), - ("c$".into(), vec![AliasValue::from("/a/index")]), + ("b$".into(), vec![AliasValue::from("a/index.js")]), + ("c$".into(), vec![AliasValue::from("/a/index.js")]), ( "multiAlias".into(), vec![ @@ -53,7 +53,7 @@ fn alias() { ), ("recursive".into(), vec![AliasValue::from("recursive/dir")]), ("/d/dir".into(), vec![AliasValue::from("/c/dir")]), - ("/d/index.js".into(), vec![AliasValue::from("/c/index")]), + ("/d/index.js".into(), vec![AliasValue::from("/c/index.js")]), ("#".into(), vec![AliasValue::from("/c/dir")]), ("@".into(), vec![AliasValue::from("/c/dir")]), ("ignored".into(), vec![AliasValue::Ignore]), @@ -75,51 +75,51 @@ fn alias() { #[rustfmt::skip] let pass = [ - ("should resolve a not aliased module 1", "a", "/a/index"), - ("should resolve a not aliased module 2", "a/index", "/a/index"), - ("should resolve a not aliased module 3", "a/dir", "/a/dir/index"), - ("should resolve a not aliased module 4", "a/dir/index", "/a/dir/index"), - ("should resolve an aliased module 1", "aliasA", "/a/index"), - ("should resolve an aliased module 2", "aliasA/index", "/a/index"), - ("should resolve an aliased module 3", "aliasA/dir", "/a/dir/index"), - ("should resolve an aliased module 4", "aliasA/dir/index", "/a/dir/index"), - ("should resolve '#' alias 1", "#", "/c/dir/index"), - ("should resolve '#' alias 2", "#/index", "/c/dir/index"), - ("should resolve '@' alias 1", "@", "/c/dir/index"), - ("should resolve '@' alias 2", "@/index", "/c/dir/index"), - ("should resolve '@' alias 3", "@/", "/c/dir/index"), - ("should resolve a recursive aliased module 1", "recursive", "/recursive/dir/index"), - ("should resolve a recursive aliased module 2", "recursive/index", "/recursive/dir/index"), - ("should resolve a recursive aliased module 3", "recursive/dir", "/recursive/dir/index"), - ("should resolve a recursive aliased module 4", "recursive/dir/index", "/recursive/dir/index"), - ("should resolve a file aliased module 1", "b", "/a/index"), - ("should resolve a file aliased module 2", "c", "/a/index"), - ("should resolve a file aliased module with a query 1", "b?query", "/a/index?query"), - ("should resolve a file aliased module with a query 2", "c?query", "/a/index?query"), - ("should resolve a path in a file aliased module 1", "b/index", "/b/index"), - ("should resolve a path in a file aliased module 2", "b/dir", "/b/dir/index"), - ("should resolve a path in a file aliased module 3", "b/dir/index", "/b/dir/index"), - ("should resolve a path in a file aliased module 4", "c/index", "/c/index"), - ("should resolve a path in a file aliased module 5", "c/dir", "/c/dir/index"), - ("should resolve a path in a file aliased module 6", "c/dir/index", "/c/dir/index"), - ("should resolve a file aliased file 1", "d", "/c/index"), - ("should resolve a file aliased file 2", "d/dir/index", "/c/dir/index"), + ("should resolve a not aliased module 1", "a", "/a/index.js"), + ("should resolve a not aliased module 2", "a/index.js", "/a/index.js"), + ("should resolve a not aliased module 3", "a/dir", "/a/dir/index.js"), + ("should resolve a not aliased module 4", "a/dir/index.js", "/a/dir/index.js"), + ("should resolve an aliased module 1", "aliasA", "/a/index.js"), + ("should resolve an aliased module 2", "aliasA/index.js", "/a/index.js"), + ("should resolve an aliased module 3", "aliasA/dir", "/a/dir/index.js"), + ("should resolve an aliased module 4", "aliasA/dir/index.js", "/a/dir/index.js"), + ("should resolve '#' alias 1", "#", "/c/dir/index.js"), + ("should resolve '#' alias 2", "#/index.js", "/c/dir/index.js"), + ("should resolve '@' alias 1", "@", "/c/dir/index.js"), + ("should resolve '@' alias 2", "@/index.js", "/c/dir/index.js"), + ("should resolve '@' alias 3", "@/", "/c/dir/index.js"), + ("should resolve a recursive aliased module 1", "recursive", "/recursive/dir/index.js"), + ("should resolve a recursive aliased module 2", "recursive/index.js", "/recursive/dir/index.js"), + ("should resolve a recursive aliased module 3", "recursive/dir", "/recursive/dir/index.js"), + ("should resolve a recursive aliased module 4", "recursive/dir/index.js", "/recursive/dir/index.js"), + ("should resolve a file aliased module 1", "b", "/a/index.js"), + ("should resolve a file aliased module 2", "c", "/a/index.js"), + ("should resolve a file aliased module with a query 1", "b?query", "/a/index.js?query"), + ("should resolve a file aliased module with a query 2", "c?query", "/a/index.js?query"), + ("should resolve a path in a file aliased module 1", "b/index.js", "/b/index.js"), + ("should resolve a path in a file aliased module 2", "b/dir", "/b/dir/index.js"), + ("should resolve a path in a file aliased module 3", "b/dir/index.js", "/b/dir/index.js"), + ("should resolve a path in a file aliased module 4", "c/index.js", "/c/index.js"), + ("should resolve a path in a file aliased module 5", "c/dir", "/c/dir/index.js"), + ("should resolve a path in a file aliased module 6", "c/dir/index.js", "/c/dir/index.js"), + ("should resolve a file aliased file 1", "d", "/c/index.js"), + ("should resolve a file aliased file 2", "d/dir/index.js", "/c/dir/index.js"), ("should resolve a file in multiple aliased dirs 1", "multiAlias/dir/file", "/e/dir/file"), - ("should resolve a file in multiple aliased dirs 2", "multiAlias/anotherDir", "/e/anotherDir/index"), + ("should resolve a file in multiple aliased dirs 2", "multiAlias/anotherDir", "/e/anotherDir/index.js"), // wildcard - ("should resolve wildcard alias 1", "@a", "/a/index"), - ("should resolve wildcard alias 2", "@a/dir", "/a/dir/index"), + ("should resolve wildcard alias 1", "@a", "/a/index.js"), + ("should resolve wildcard alias 2", "@a/dir", "/a/dir/index.js"), ("should resolve wildcard alias 3", "@e/dir/file", "/e/dir/file"), - ("should resolve wildcard alias 4", "@e/anotherDir", "/e/anotherDir/index"), + ("should resolve wildcard alias 4", "@e/anotherDir", "/e/anotherDir/index.js"), ("should resolve wildcard alias 5", "@e/dir/file", "/e/dir/file"), // added to test value without wildcard - ("should resolve scoped package name with sub dir 1", "@adir/index", "/a/index"), - ("should resolve scoped package name with sub dir 2", "@adir/dir", "/a/index"), + ("should resolve scoped package name with sub dir 1", "@adir/index.js", "/a/index.js"), + ("should resolve scoped package name with sub dir 2", "@adir/dir", "/a/index.js"), // not part of enhanced-resolve, added to make sure query in alias value works - ("should resolve query in alias value", "alias_query?query_before", "/a/index?query_after"), - ("should resolve query in alias value", "alias_fragment#fragment_before", "/a/index#fragment_after"), + ("should resolve query in alias value", "alias_query?query_before", "/a/index.js?query_after"), + ("should resolve query in alias value", "alias_fragment#fragment_before", "/a/index.js#fragment_after"), ("should resolve dashed name", "dashed-name", "/dashed-name"), - ("should resolve scoped package name with sub dir", "@scope/package-name/file", "/c/dir/index"), + ("should resolve scoped package name with sub dir", "@scope/package-name/file", "/c/dir/index.js"), ]; for (comment, request, expected) in pass { diff --git a/src/tests/dependencies.rs b/src/tests/dependencies.rs index ee4e441e..02f0d946 100644 --- a/src/tests/dependencies.rs +++ b/src/tests/dependencies.rs @@ -1,7 +1,7 @@ //! https://github.com/webpack/enhanced-resolve/blob/main/test/dependencies.test.js #[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows. -mod windows { +mod test { use std::path::PathBuf; use super::super::memory_fs::MemoryFS; @@ -18,17 +18,6 @@ mod windows { #[test] fn test() { - let file_system = file_system(); - - let resolver = ResolverGeneric::new_with_file_system( - file_system, - ResolveOptions { - extensions: vec![".json".into(), ".js".into()], - modules: vec!["/modules".into(), "node_modules".into()], - ..ResolveOptions::default() - }, - ); - let data = [ ( "middle module request", @@ -92,6 +81,15 @@ mod windows { ]; for (name, context, request, result, file_dependencies, missing_dependencies) in data { + let file_system = file_system(); + let resolver = ResolverGeneric::new_with_file_system( + file_system, + ResolveOptions { + extensions: vec![".json".into(), ".js".into()], + modules: vec!["/modules".into(), "node_modules".into()], + ..ResolveOptions::default() + }, + ); let mut ctx = ResolveContext::default(); let path = PathBuf::from(context); let resolved_path = diff --git a/src/tests/extension_alias.rs b/src/tests/extension_alias.rs index 43f2f075..4978bc1c 100644 --- a/src/tests/extension_alias.rs +++ b/src/tests/extension_alias.rs @@ -57,7 +57,7 @@ fn not_apply_to_extension_nor_main_files() { let resolver = Resolver::new(ResolveOptions { extensions: vec![".js".into()], - main_files: vec!["index.js".into()], + main_files: vec!["index".into()], extension_alias: vec![(".js".into(), vec![])], ..ResolveOptions::default() }); diff --git a/src/tests/fallback.rs b/src/tests/fallback.rs index c5bd4e76..8dccc3c8 100644 --- a/src/tests/fallback.rs +++ b/src/tests/fallback.rs @@ -11,20 +11,20 @@ fn fallback() { let f = Path::new("/"); let file_system = MemoryFS::new(&[ - ("/a/index", ""), - ("/a/dir/index", ""), - ("/recursive/index", ""), - ("/recursive/dir/index", ""), + ("/a/index.js", ""), + ("/a/dir/index.js", ""), + ("/recursive/index.js", ""), + ("/recursive/dir/index.js", ""), ("/recursive/dir/file", ""), - ("/recursive/dir/dir/index", ""), - ("/b/index", ""), - ("/b/dir/index", ""), - ("/c/index", ""), - ("/c/dir/index", ""), - ("/d/index.js", ""), + ("/recursive/dir/dir/index.js", ""), + ("/b/index.js", ""), + ("/b/dir/index.js", ""), + ("/c/index.js", ""), + ("/c/dir/index.js", ""), + ("/d/index.js.js", ""), ("/d/dir/.empty", ""), - ("/e/index", ""), - ("/e/anotherDir/index", ""), + ("/e/index.js", ""), + ("/e/anotherDir/index.js", ""), ("/e/dir/file", ""), ]); @@ -33,8 +33,8 @@ fn fallback() { ResolveOptions { fallback: vec![ ("aliasA".into(), vec![AliasValue::Path("a".into())]), - ("b$".into(), vec![AliasValue::Path("a/index".into())]), - ("c$".into(), vec![AliasValue::Path("/a/index".into())]), + ("b$".into(), vec![AliasValue::Path("a/index.js".into())]), + ("c$".into(), vec![AliasValue::Path("/a/index.js".into())]), ( "multiAlias".into(), vec![ @@ -47,7 +47,7 @@ fn fallback() { ), ("recursive".into(), vec![AliasValue::Path("recursive/dir".into())]), ("/d/dir".into(), vec![AliasValue::Path("/c/dir".into())]), - ("/d/index.js".into(), vec![AliasValue::Path("/c/index".into())]), + ("/d/index.js.js".into(), vec![AliasValue::Path("/c/index.js".into())]), ("ignored".into(), vec![AliasValue::Ignore]), ("node:path".into(), vec![AliasValue::Ignore]), ], @@ -58,29 +58,29 @@ fn fallback() { #[rustfmt::skip] let pass = [ - ("should resolve a not aliased module 1", "a", "/a/index"), - ("should resolve a not aliased module 2", "a/index", "/a/index"), - ("should resolve a not aliased module 3", "a/dir", "/a/dir/index"), - ("should resolve a not aliased module 4", "a/dir/index", "/a/dir/index"), - ("should resolve an fallback module 1", "aliasA", "/a/index"), - ("should resolve an fallback module 2", "aliasA/index", "/a/index"), - ("should resolve an fallback module 3", "aliasA/dir", "/a/dir/index"), - ("should resolve an fallback module 4", "aliasA/dir/index", "/a/dir/index"), - ("should resolve a recursive aliased module 1", "recursive", "/recursive/index"), - ("should resolve a recursive aliased module 2", "recursive/index", "/recursive/index"), - ("should resolve a recursive aliased module 3", "recursive/dir", "/recursive/dir/index"), - ("should resolve a recursive aliased module 4", "recursive/dir/index", "/recursive/dir/index"), + ("should resolve a not aliased module 1", "a", "/a/index.js"), + ("should resolve a not aliased module 2", "a/index.js", "/a/index.js"), + ("should resolve a not aliased module 3", "a/dir", "/a/dir/index.js"), + ("should resolve a not aliased module 4", "a/dir/index.js", "/a/dir/index.js"), + ("should resolve an fallback module 1", "aliasA", "/a/index.js"), + ("should resolve an fallback module 2", "aliasA/index.js", "/a/index.js"), + ("should resolve an fallback module 3", "aliasA/dir", "/a/dir/index.js"), + ("should resolve an fallback module 4", "aliasA/dir/index.js", "/a/dir/index.js"), + ("should resolve a recursive aliased module 1", "recursive", "/recursive/index.js"), + ("should resolve a recursive aliased module 2", "recursive/index.js", "/recursive/index.js"), + ("should resolve a recursive aliased module 3", "recursive/dir", "/recursive/dir/index.js"), + ("should resolve a recursive aliased module 4", "recursive/dir/index.js", "/recursive/dir/index.js"), ("should resolve a recursive aliased module 5", "recursive/file", "/recursive/dir/file"), - ("should resolve a file aliased module with a query 1", "b?query", "/b/index?query"), - ("should resolve a file aliased module with a query 2", "c?query", "/c/index?query"), - ("should resolve a path in a file aliased module 1", "b/index", "/b/index"), - ("should resolve a path in a file aliased module 2", "b/dir", "/b/dir/index"), - ("should resolve a path in a file aliased module 3", "b/dir/index", "/b/dir/index"), - ("should resolve a path in a file aliased module 4", "c/index", "/c/index"), - ("should resolve a path in a file aliased module 5", "c/dir", "/c/dir/index"), - ("should resolve a path in a file aliased module 6", "c/dir/index", "/c/dir/index"), + ("should resolve a file aliased module with a query 1", "b?query", "/b/index.js?query"), + ("should resolve a file aliased module with a query 2", "c?query", "/c/index.js?query"), + ("should resolve a path in a file aliased module 1", "b/index.js", "/b/index.js"), + ("should resolve a path in a file aliased module 2", "b/dir", "/b/dir/index.js"), + ("should resolve a path in a file aliased module 3", "b/dir/index.js", "/b/dir/index.js"), + ("should resolve a path in a file aliased module 4", "c/index.js", "/c/index.js"), + ("should resolve a path in a file aliased module 5", "c/dir", "/c/dir/index.js"), + ("should resolve a path in a file aliased module 6", "c/dir/index.js", "/c/dir/index.js"), ("should resolve a file in multiple aliased dirs 1", "multiAlias/dir/file", "/e/dir/file"), - ("should resolve a file in multiple aliased dirs 2", "multiAlias/anotherDir", "/e/anotherDir/index"), + ("should resolve a file in multiple aliased dirs 2", "multiAlias/anotherDir", "/e/anotherDir/index.js"), ]; for (comment, request, expected) in pass { diff --git a/src/tests/imports_field.rs b/src/tests/imports_field.rs index 7ec62429..95a34344 100644 --- a/src/tests/imports_field.rs +++ b/src/tests/imports_field.rs @@ -15,7 +15,7 @@ fn test_simple() { let resolver = Resolver::new(ResolveOptions { extensions: vec![".js".into()], - main_files: vec!["index.js".into()], + main_files: vec!["index".into()], condition_names: vec!["webpack".into()], ..ResolveOptions::default() }); diff --git a/src/tests/memory_fs.rs b/src/tests/memory_fs.rs index 6044d8a6..b0be28f6 100644 --- a/src/tests/memory_fs.rs +++ b/src/tests/memory_fs.rs @@ -86,4 +86,13 @@ impl FileSystem for MemoryFS { fn read_link(&self, _path: &Path) -> Result { Err(io::Error::new(io::ErrorKind::NotFound, "not a symlink").into()) } + + fn canonicalize(&self, path: &Path) -> io::Result { + // MemoryFS doesn't support symlinks, so just verify path exists and return it + use vfs::FileSystem; + self.fs + .metadata(path.to_string_lossy().as_ref()) + .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; + Ok(path.to_path_buf()) + } } diff --git a/src/tests/missing.rs b/src/tests/missing.rs index 0a284f5d..28f2d509 100644 --- a/src/tests/missing.rs +++ b/src/tests/missing.rs @@ -37,7 +37,6 @@ fn test() { ( "m1/", vec![ - f.join("node_modules/m1/index"), f.join("node_modules/m1/index.js"), f.join("node_modules/m1/index.json"), f.join("node_modules/m1/index.node"), diff --git a/src/tests/package_json.rs b/src/tests/package_json.rs index 45643475..4b58e34f 100644 --- a/src/tests/package_json.rs +++ b/src/tests/package_json.rs @@ -1,6 +1,6 @@ //! Tests for `Resolution::package_json`. -use crate::{ResolveError, Resolver}; +use crate::Resolver; #[test] fn test() { @@ -68,8 +68,9 @@ fn package_json_with_symlinks_true() { fn test_corrupted_package_json() { use std::path::Path; + use crate::{ResolveError, ResolveOptions, ResolverGeneric}; + use super::memory_fs::MemoryFS; - use crate::{ResolveOptions, ResolverGeneric}; // Test scenarios for various corrupted package.json files let scenarios = [ diff --git a/src/tests/resolve.rs b/src/tests/resolve.rs index d6181981..8ea336af 100644 --- a/src/tests/resolve.rs +++ b/src/tests/resolve.rs @@ -124,20 +124,6 @@ fn resolve_hash_as_module() { assert_eq!(resolution, Err(ResolveError::NotFound("#a".into()))); } -#[test] -fn prefer_file_over_dir() { - let f = super::fixture_root().join("prefer-file-over-dir"); - let resolver = Resolver::default(); - let data = [ - ("one level package name", f.clone(), "bar", f.join("node_modules/bar.js")), - ("scoped level package name", f.clone(), "@foo/bar", f.join("node_modules/@foo/bar.js")), - ]; - for (comment, path, request, expected) in data { - let resolved_path = resolver.resolve(&path, request).map(|r| r.full_path()); - assert_eq!(resolved_path, Ok(expected), "{comment} {path:?} {request}"); - } -} - #[test] fn resolve_edge_cases() { let f = super::fixture(); @@ -179,22 +165,6 @@ fn resolve_dot() { } } -#[test] -fn symlink_with_nested_node_modules() { - let f = super::fixture_root().join("symlink-with-nested-node_modules"); - - let resolver = Resolver::default(); - let resolved_path = - resolver.resolve(f.join("bar/node_modules/foo"), "dep").map(|r| r.full_path()); - assert_eq!(resolved_path, Ok(f.join("foo/node_modules/dep/index.js"))); - - let resolver = Resolver::new(ResolveOptions { symlinks: false, ..ResolveOptions::default() }); - assert_eq!( - resolver.resolve(f.join("bar/node_modules/foo"), "dep"), - Err(ResolveError::NotFound("dep".into())) - ); -} - #[test] fn abnormal_relative() { let f = super::fixture_root().join("abnormal-relative-with-node_modules"); diff --git a/src/tests/symlink.rs b/src/tests/symlink.rs index f15e0084..24738fc7 100644 --- a/src/tests/symlink.rs +++ b/src/tests/symlink.rs @@ -174,7 +174,7 @@ fn test() { fn test_unsupported_targets() { use crate::ResolveError; - let Some(SymlinkFixturePaths { root: _, temp_path }) = + let Some(SymlinkFixturePaths { root, temp_path }) = prepare_symlinks("temp.test_unsupported_targets").unwrap() else { return; @@ -200,9 +200,10 @@ fn test_unsupported_targets() { // from `FsCachedPath::find_package_json` when trying to canonicalize the full path of `package.json`. // * Otherwise, a `ResolveError::NotFound` will be returned. let dos_device_temp_path = get_dos_device_path(&temp_path).unwrap(); + let dos_device_root = get_dos_device_path(&root).unwrap(); assert_eq!( resolver_with_symlinks.resolve(&dos_device_temp_path, "./index.js"), - Err(ResolveError::PathNotSupported(dos_device_temp_path)) + Err(ResolveError::PathNotSupported(dos_device_root)) ); } diff --git a/src/tsconfig_context.rs b/src/tsconfig_context.rs deleted file mode 100644 index 9becbea5..00000000 --- a/src/tsconfig_context.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::path::{Path, PathBuf}; - -#[derive(Default)] -pub struct TsconfigResolveContext { - extended_configs: Vec, -} - -impl TsconfigResolveContext { - pub fn with_extended_file R>(&mut self, path: PathBuf, cb: T) -> R { - self.extended_configs.push(path); - let result = cb(self); - self.extended_configs.pop(); - result - } - - pub fn is_already_extended(&self, path: &Path) -> bool { - self.extended_configs.iter().any(|config| config == path) - } - - pub fn get_extended_configs_with(&self, path: PathBuf) -> Vec { - let mut new_vec = Vec::with_capacity(self.extended_configs.len() + 1); - new_vec.extend_from_slice(&self.extended_configs); - new_vec.push(path); - new_vec - } -} diff --git a/src/tsconfig_resolver.rs b/src/tsconfig_resolver.rs new file mode 100644 index 00000000..8d7642f9 --- /dev/null +++ b/src/tsconfig_resolver.rs @@ -0,0 +1,257 @@ +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; + +use crate::{ + CachedPath, Ctx, FileSystem, ResolveError, ResolveOptions, ResolveResult, ResolverGeneric, + SpecifierError, TsConfig, TsconfigDiscovery, TsconfigReferences, path::PathUtil, +}; + +#[derive(Default)] +pub struct TsconfigResolveContext { + extended_configs: Vec, +} + +impl TsconfigResolveContext { + pub fn with_extended_file R>(&mut self, path: PathBuf, cb: T) -> R { + self.extended_configs.push(path); + let result = cb(self); + self.extended_configs.pop(); + result + } + + pub fn is_already_extended(&self, path: &Path) -> bool { + self.extended_configs.iter().any(|config| config == path) + } + + pub fn get_extended_configs_with(&self, path: PathBuf) -> Vec { + let mut new_vec = Vec::with_capacity(self.extended_configs.len() + 1); + new_vec.extend_from_slice(&self.extended_configs); + new_vec.push(path); + new_vec + } +} + +impl ResolverGeneric { + /// Resolve `tsconfig`. + /// + /// The path can be: + /// + /// * Path to a file with `.json` extension. + /// * Path to a file without `.json` extension, `.json` will be appended to filename. + /// * Path to a directory, where the filename is defaulted to `tsconfig.json` + /// + /// # Errors + /// + /// * See [ResolveError] + pub fn resolve_tsconfig>(&self, path: P) -> Result, ResolveError> { + let path = path.as_ref(); + self.load_tsconfig( + true, + path, + &TsconfigReferences::Auto, + &mut TsconfigResolveContext::default(), + ) + } + + fn load_tsconfig( + &self, + root: bool, + path: &Path, + references: &TsconfigReferences, + ctx: &mut TsconfigResolveContext, + ) -> Result, ResolveError> { + self.cache.get_tsconfig(root, path, |tsconfig| { + let directory = self.cache.value(tsconfig.directory()); + tracing::trace!(tsconfig = ?tsconfig, "load_tsconfig"); + + if ctx.is_already_extended(tsconfig.path()) { + return Err(ResolveError::TsconfigCircularExtend( + ctx.get_extended_configs_with(tsconfig.path().to_path_buf()).into(), + )); + } + + // Extend tsconfig + let extended_tsconfig_paths = tsconfig + .extends() + .map(|specifier| self.get_extended_tsconfig_path(&directory, tsconfig, specifier)) + .collect::, _>>()?; + if !extended_tsconfig_paths.is_empty() { + ctx.with_extended_file(tsconfig.path().to_owned(), |ctx| { + for extended_tsconfig_path in extended_tsconfig_paths { + let extended_tsconfig = self.load_tsconfig( + /* root */ false, + &extended_tsconfig_path, + &TsconfigReferences::Disabled, + ctx, + )?; + tsconfig.extend_tsconfig(&extended_tsconfig); + } + Result::Ok::<(), ResolveError>(()) + })?; + } + + if tsconfig.load_references(references) { + let path = tsconfig.path().to_path_buf(); + let directory = tsconfig.directory().to_path_buf(); + for reference in tsconfig.references_mut() { + let reference_tsconfig_path = directory.normalize_with(reference.path()); + let 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(()) + }, + )?; + reference.set_tsconfig(tsconfig); + } + } + 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::, _>>()?; + for extended_tsconfig_path in extended_tsconfig_paths { + let extended_tsconfig = self.load_tsconfig( + /* root */ false, + &extended_tsconfig_path, + &TsconfigReferences::Disabled, + ctx, + )?; + tsconfig.extend_tsconfig(&extended_tsconfig); + } + Ok(()) + } + + pub(crate) fn load_tsconfig_paths( + &self, + cached_path: &CachedPath, + specifier: &str, + ctx: &mut Ctx, + ) -> ResolveResult { + if cached_path.inside_node_modules() { + return Ok(None); + } + let tsconfig = match &self.options.tsconfig { + None => return Ok(None), + Some(TsconfigDiscovery::Manual(tsconfig_options)) => { + let tsconfig = self.load_tsconfig( + /* root */ true, + &tsconfig_options.config_file, + &tsconfig_options.references, + &mut TsconfigResolveContext::default(), + )?; + // Cache the loaded tsconfig in the path's directory + let tsconfig_dir = self.cache.value(tsconfig.directory()); + _ = tsconfig_dir.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig))); + tsconfig + } + Some(TsconfigDiscovery::Auto) => { + let Some(tsconfig) = self.find_tsconfig(cached_path, ctx)? else { + return Ok(None); + }; + tsconfig + } + }; + + let paths = tsconfig.resolve(cached_path.path(), specifier); + for path in paths { + let resolved_path = self.cache.value(&path); + if let Some(resolution) = self.load_as_file_or_directory(&resolved_path, ".", ctx)? { + // Cache the tsconfig in the resolved path + _ = resolved_path.tsconfig.get_or_init(|| Some(Arc::clone(&tsconfig))); + return Ok(Some(resolution)); + } + } + Ok(None) + } + + /// Find tsconfig.json of a path by traversing parent directories. + /// + /// # Errors + /// + /// * [ResolveError::Json] + pub(crate) fn find_tsconfig( + &self, + cached_path: &CachedPath, + ctx: &mut Ctx, + ) -> Result>, ResolveError> { + // Don't discover tsconfig for paths inside node_modules + if cached_path.inside_node_modules() { + return Ok(None); + } + // Skip non-absolute paths (e.g. virtual modules) + if !cached_path.path.is_absolute() { + return Ok(None); + } + + let mut cache_value = Some(cached_path.clone()); + while let Some(cv) = cache_value { + if let Some(tsconfig) = cv.tsconfig.get_or_try_init(|| { + let tsconfig_path = cv.path.join("tsconfig.json"); + let tsconfig_path = self.cache.value(&tsconfig_path); + if self.cache.is_file(&tsconfig_path, ctx) { + self.resolve_tsconfig(tsconfig_path.path()).map(Some) + } else { + Ok(None) + } + })? { + return Ok(Some(Arc::clone(tsconfig))); + } + cache_value = cv.parent(); + } + Ok(None) + } + + fn get_extended_tsconfig_path( + &self, + directory: &CachedPath, + tsconfig: &TsConfig, + specifier: &str, + ) -> Result { + match specifier.as_bytes().first() { + None => Err(ResolveError::Specifier(SpecifierError::Empty(specifier.to_string()))), + Some(b'/') => Ok(PathBuf::from(specifier)), + Some(b'.') => Ok(tsconfig.directory().normalize_with(specifier)), + _ => self + .clone_with_options(ResolveOptions { + tsconfig: None, + extensions: vec![".json".into()], + main_files: vec!["tsconfig".into()], + #[cfg(feature = "yarn_pnp")] + yarn_pnp: self.options.yarn_pnp, + #[cfg(feature = "yarn_pnp")] + cwd: self.options.cwd.clone(), + ..ResolveOptions::default() + }) + .load_package_self_or_node_modules(directory, specifier, &mut Ctx::default()) + .map(|p| p.to_path_buf()) + .map_err(|err| match err { + ResolveError::NotFound(_) => { + ResolveError::TsconfigNotFound(PathBuf::from(specifier)) + } + _ => err, + }), + } + } +}