1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
use std::{
borrow::Cow,
collections::HashSet as StdHashSet,
hash::{BuildHasherDefault, Hash, Hasher},
io,
path::{Path, PathBuf},
sync::Arc,
};
use cfg_if::cfg_if;
#[cfg(feature = "yarn_pnp")]
use once_cell::sync::OnceCell;
use papaya::{HashMap, HashSet};
use rustc_hash::FxHasher;
use super::borrowed_path::BorrowedCachedPath;
use super::cached_path::{CachedPath, CachedPathImpl};
use super::hasher::IdentityHasher;
use crate::{
FileMetadata, FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig,
context::ResolveContext as Ctx, path::PathUtil,
};
/// Cache implementation used for caching filesystem access.
#[derive(Default)]
pub struct Cache<Fs> {
pub(crate) fs: Fs,
pub(crate) paths: HashSet<CachedPath, BuildHasherDefault<IdentityHasher>>,
/// Cache for raw/unbuilt tsconfigs (used when extending).
pub(crate) tsconfigs_raw: HashMap<PathBuf, Arc<TsConfig>, BuildHasherDefault<FxHasher>>,
/// Cache for built/resolved tsconfigs (used for resolution).
pub(crate) tsconfigs_built: HashMap<PathBuf, Arc<TsConfig>, BuildHasherDefault<FxHasher>>,
#[cfg(feature = "yarn_pnp")]
pub(crate) yarn_pnp_manifest: OnceCell<pnp::Manifest>,
}
impl<Fs: FileSystem> Cache<Fs> {
pub fn clear(&self) {
self.paths.pin().clear();
self.tsconfigs_raw.pin().clear();
self.tsconfigs_built.pin().clear();
}
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn value(&self, path: &Path) -> CachedPath {
// `Path::hash` is slow: https://doc.rust-lang.org/std/path/struct.Path.html#impl-Hash-for-Path
// `path.as_os_str()` hash is not stable because we may joined a path like `foo/bar` and `foo\\bar` on windows.
let hash = {
let mut hasher = FxHasher::default();
path.as_os_str().hash(&mut hasher);
hasher.finish()
};
let paths = self.paths.pin();
if let Some(entry) = paths.get(&BorrowedCachedPath { hash, path }) {
return entry.clone();
}
let parent = path.parent().map(|p| self.value(p));
let is_node_modules = path.file_name().as_ref().is_some_and(|&name| name == "node_modules");
let inside_node_modules =
is_node_modules || parent.as_ref().is_some_and(|parent| parent.inside_node_modules);
let parent_weak = parent.as_ref().map(|p| Arc::downgrade(&p.0));
let cached_path = CachedPath(Arc::new(CachedPathImpl::new(
hash,
path.to_path_buf().into_boxed_path(),
is_node_modules,
inside_node_modules,
parent_weak,
)));
paths.insert(cached_path.clone());
cached_path
}
pub(crate) fn canonicalize(&self, path: &CachedPath) -> Result<PathBuf, ResolveError> {
let cached_path = self.canonicalize_impl(path)?;
let path = cached_path.to_path_buf();
cfg_if! {
if #[cfg(target_os = "windows")] {
crate::windows::strip_windows_prefix(path)
} else {
Ok(path)
}
}
}
pub(crate) fn is_file(&self, path: &CachedPath, symlinks: bool, ctx: &mut Ctx) -> bool {
if self.followed_metadata(path, symlinks).is_some_and(FileMetadata::is_file) {
ctx.add_file_dependency(path.path());
true
} else {
ctx.add_missing_dependency(path.path());
false
}
}
pub(crate) fn is_dir(&self, path: &CachedPath, symlinks: bool, ctx: &mut Ctx) -> bool {
self.followed_metadata(path, symlinks).map_or_else(
|| {
ctx.add_missing_dependency(path.path());
false
},
FileMetadata::is_dir,
)
}
/// `stat`-equivalent metadata (symlinks followed) for `path`, cached in the `followed` slot.
///
/// For a non-symlink the cached `lstat` already answers this, so no extra syscall is issued.
/// For a symlink with `symlinks` enabled, reuse canonicalization — which the resolver performs
/// anyway for the final resolved path — and read the canonical target's already-cached `lstat`,
/// avoiding a standalone `stat` of the symlink.
///
/// Falls back to a direct `stat` when symlinks are disabled, when canonicalization fails, or
/// when the canonical target has no metadata. The last case keeps the optimization purely
/// additive: a custom [`FileSystem`] whose `canonicalize` and `metadata` disagree still gets
/// the same answer `stat` gave before.
fn followed_metadata(&self, path: &CachedPath, symlinks: bool) -> Option<FileMetadata> {
path.meta.followed_or_init(|| match path.link_metadata(&self.fs) {
Some(meta) if meta.is_symlink() => {
let followed = if symlinks {
self.canonicalize_impl(path).ok().and_then(|c| c.link_metadata(&self.fs))
} else {
None
};
followed.or_else(|| self.fs.metadata(path.path()).ok())
}
// A non-symlink's `lstat` already is its `stat`; `None` stays `None`.
other => other,
})
}
/// 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<Option<Arc<PackageJson>>, 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<Option<Arc<PackageJson>>, ResolveError> {
let mut path = path.clone();
// Go up directories when the querying path is not a directory
while !self.is_dir(&path, options.symlinks, ctx) {
if let Some(cv) = path.parent(self) {
path = cv;
} else {
break;
}
}
self.find_package_json_impl(&path, options, ctx)
}
/// 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<Option<Arc<PackageJson>>, ResolveError> {
// Change to `std::sync::OnceLock::get_or_try_init` when it is stable.
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 {
if let Some(deps) = &mut ctx.missing_dependencies {
deps.push(package_json_path);
}
return path.parent(self).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()
};
// Move `package_json_path` into `parse` instead of cloning it: the parsed
// `PackageJson` stores the path verbatim (`package_json.path()`), and on error
// `JSONError.path` carries the same path, so the file-dependency record reads it
// back without a second allocation.
// https://github.com/webpack/enhanced-resolve/blob/58464fc7cb56673c9aa849e68e6300239601e615/lib/DescriptionFileUtils.js#L68-L82
match PackageJson::parse(&self.fs, package_json_path, real_path, package_json_bytes)
{
Ok(package_json) => {
ctx.add_file_dependency(package_json.path());
Ok(Some(Arc::new(package_json)))
}
Err(error) => {
if let Some(deps) = &mut ctx.file_dependencies {
deps.push(error.path.clone());
}
Err(ResolveError::Json(error))
}
}
})
.cloned()
}
pub(crate) fn get_tsconfig<F: FnOnce(&mut TsConfig) -> Result<(), ResolveError>>(
&self,
root: bool,
path: &Path,
callback: F, // callback for modifying tsconfig with `extends`
) -> Result<Arc<TsConfig>, ResolveError> {
// For root=true (caller tsconfig), check built cache first
if root {
let tsconfigs_built = self.tsconfigs_built.pin();
if let Some(tsconfig) = tsconfigs_built.get(path) {
return Ok(Arc::clone(tsconfig));
}
}
// Check raw cache (callback applied, not built) - only for root=false
// For root=true, we need to run the callback to ensure extends are processed
if !root {
let tsconfigs_raw = self.tsconfigs_raw.pin();
if let Some(tsconfig) = tsconfigs_raw.get(path) {
return Ok(Arc::clone(tsconfig));
}
}
// Not in any cache, parse from file
let meta = self.fs.metadata(path).ok();
let tsconfig_path = if meta.is_some_and(|m| m.is_file) {
Cow::Borrowed(path)
} else if meta.is_some_and(|m| m.is_dir) {
Cow::Owned(path.join("tsconfig.json"))
} else {
let mut os_string = path.to_path_buf().into_os_string();
os_string.push(".json");
Cow::Owned(PathBuf::from(os_string))
};
let tsconfig_string = self.fs.read_to_string(&tsconfig_path).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
ResolveError::TsconfigNotFound(path.to_path_buf())
} else {
ResolveError::from(err)
}
})?;
let canonical_path = self
.canonicalize(&self.value(&tsconfig_path))
.unwrap_or_else(|_| tsconfig_path.to_path_buf());
let mut tsconfig = TsConfig::parse(root, &tsconfig_path, &canonical_path, tsconfig_string)
.map_err(|error| {
ResolveError::from_serde_json_error(tsconfig_path.to_path_buf(), &error)
})?;
// Run callback (extends/references processing)
callback(&mut tsconfig)?;
// Cache raw version (callback applied, not built)
tsconfig.set_should_build(false);
let raw_tsconfig = Arc::new(tsconfig.clone());
self.tsconfigs_raw.pin().insert(path.to_path_buf(), Arc::clone(&raw_tsconfig));
if root {
// Build and cache built version
tsconfig.set_should_build(true);
let tsconfig = Arc::new(tsconfig.build());
self.tsconfigs_built.pin().insert(path.to_path_buf(), Arc::clone(&tsconfig));
Ok(tsconfig)
} else {
// Return unbuilt version
Ok(raw_tsconfig)
}
}
#[cfg(feature = "yarn_pnp")]
pub(crate) fn get_yarn_pnp_manifest(
&self,
cwd: Option<&Path>,
) -> Result<&pnp::Manifest, ResolveError> {
self.yarn_pnp_manifest.get_or_try_init(|| {
let cwd = match cwd {
Some(path) => Cow::Borrowed(path),
None => match std::env::current_dir() {
Ok(path) => Cow::Owned(path),
Err(err) => return Err(ResolveError::from(err)),
},
};
let manifest = match pnp::find_pnp_manifest(&cwd) {
Ok(manifest) => match manifest {
Some(manifest) => manifest,
None => {
return Err(ResolveError::FailedToFindYarnPnpManifest(cwd.to_path_buf()));
}
},
Err(err) => return Err(ResolveError::YarnPnpError(err)),
};
Ok(manifest)
})
}
}
impl<Fs: FileSystem> Cache<Fs> {
pub fn new(fs: Fs) -> Self {
Self {
fs,
paths: HashSet::builder()
.hasher(BuildHasherDefault::default())
.resize_mode(papaya::ResizeMode::Blocking)
.build(),
tsconfigs_raw: HashMap::builder()
.hasher(BuildHasherDefault::default())
.resize_mode(papaya::ResizeMode::Blocking)
.build(),
tsconfigs_built: HashMap::builder()
.hasher(BuildHasherDefault::default())
.resize_mode(papaya::ResizeMode::Blocking)
.build(),
#[cfg(feature = "yarn_pnp")]
yarn_pnp_manifest: OnceCell::new(),
}
}
/// Returns the canonical path, resolving all symbolic links.
///
/// <https://github.com/parcel-bundler/parcel/blob/4d27ec8b8bd1792f536811fef86e74a31fa0e704/crates/parcel-resolver/src/cache.rs#L232>
pub(crate) fn canonicalize_impl(&self, path: &CachedPath) -> Result<CachedPath, ResolveError> {
// Each canonicalization chain gets its own visited set for circular symlink detection
let mut visited = StdHashSet::with_hasher(BuildHasherDefault::<IdentityHasher>::default());
// 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<u64, BuildHasherDefault<IdentityHasher>>,
) -> Result<CachedPath, ResolveError> {
// Check cache first - if this path was already canonicalized, return the cached result
if let Some((weak, path_box)) = path.canonicalized.get() {
return weak
.upgrade()
.map(CachedPath)
.or_else(|| {
// Weak pointer upgrade failed - recreate from the stored canonical path
Some(self.value(path_box))
})
.ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "Cached path no longer exists").into()
});
}
// 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(self).map_or_else(
|| Ok(path.normalize_root(self)),
|parent| {
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 path.link_metadata(&self.fs).is_some_and(|m| m.is_symlink) {
let link = self.fs.read_link(normalized.path())?;
if link.is_absolute() {
return self.canonicalize_with_visited(
&self.value(&link.normalize()),
visited,
);
} else if let Some(dir) = normalized.parent(self) {
// Symlink is relative `../../foo.js`, use the path directory
// to resolve this symlink.
return self.canonicalize_with_visited(
&dir.normalize_with(&link, self),
visited,
);
}
debug_assert!(
false,
"Failed to get path parent for {}.",
normalized.path().display()
);
}
Ok(normalized)
})
},
)?;
// 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.0.path.clone()));
// Remove from visited set when unwinding the recursion
visited.remove(&path.hash);
Ok(res)
}
}