diff --git a/src/alias.rs b/src/alias.rs index d7c361343..fd308c8ef 100644 --- a/src/alias.rs +++ b/src/alias.rs @@ -178,7 +178,7 @@ impl ResolverGeneric { let alias_path = Path::new(alias_value).normalize(); // Must not append anything to alias_value if it is a file. let cached_alias_path = self.cache.value(&alias_path); - if self.cache.is_file(&cached_alias_path, ctx) { + if self.is_file(&cached_alias_path, ctx) { return Ok(None); } // Remove the leading slash so the final path is concatenated. diff --git a/src/cache/cache_impl.rs b/src/cache/cache_impl.rs index fc2905334..5032dd514 100644 --- a/src/cache/cache_impl.rs +++ b/src/cache/cache_impl.rs @@ -17,7 +17,7 @@ use super::borrowed_path::BorrowedCachedPath; use super::cached_path::{CachedPath, CachedPathImpl}; use super::hasher::IdentityHasher; use crate::{ - FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig, + FileMetadata, FileSystem, PackageJson, ResolveError, ResolveOptions, TsConfig, context::ResolveContext as Ctx, path::PathUtil, }; @@ -82,8 +82,8 @@ impl Cache { } } - pub(crate) fn is_file(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { - if path.is_file(&self.fs).is_some_and(|b| b) { + 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 { @@ -92,10 +92,39 @@ impl Cache { } } - pub(crate) fn is_dir(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { - path.is_dir(&self.fs).unwrap_or_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 { + 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, }) } @@ -133,7 +162,7 @@ impl Cache { ) -> Result>, ResolveError> { let mut path = path.clone(); // Go up directories when the querying path is not a directory - while !self.is_dir(&path, ctx) { + while !self.is_dir(&path, options.symlinks, ctx) { if let Some(cv) = path.parent(self) { path = cv; } else { diff --git a/src/cache/cached_path.rs b/src/cache/cached_path.rs index 90b054209..a48ddc3d8 100644 --- a/src/cache/cached_path.rs +++ b/src/cache/cached_path.rs @@ -100,21 +100,24 @@ impl CachedPath { pub(crate) fn module_directory( &self, module_name: &str, + symlinks: bool, cache: &Cache, ctx: &mut Ctx, ) -> Option { let cached_path = self.push(module_name, cache); - cache.is_dir(&cached_path, ctx).then_some(cached_path) + cache.is_dir(&cached_path, symlinks, ctx).then_some(cached_path) } pub(crate) fn cached_node_modules( &self, + symlinks: bool, cache: &Cache, ctx: &mut Ctx, ) -> Option { self.node_modules .get_or_init(|| { - self.module_directory("node_modules", cache, ctx).map(|cp| Arc::downgrade(&cp.0)) + self.module_directory("node_modules", symlinks, cache, ctx) + .map(|cp| Arc::downgrade(&cp.0)) }) .as_ref() .and_then(|weak| { @@ -246,26 +249,6 @@ impl CachedPath { pub(crate) fn link_metadata(&self, fs: &Fs) -> Option { self.meta.link_or_init(|| fs.symlink_metadata(&self.path).ok()) } - - /// `stat` view of this path (symlinks followed), cached. - /// - /// For a non-symlink this reuses the cached `lstat` result and issues no extra syscall; only - /// an actual symlink needs a follow-up `stat` to learn what it points at. - fn followed_metadata(&self, fs: &Fs) -> Option { - self.meta.followed_or_init(|| match self.link_metadata(fs) { - Some(meta) if meta.is_symlink() => fs.metadata(&self.path).ok(), - // A non-symlink's `lstat` already is its `stat`; `None` stays `None`. - other => other, - }) - } - - pub(crate) fn is_file(&self, fs: &Fs) -> Option { - self.followed_metadata(fs).map(FileMetadata::is_file) - } - - pub(crate) fn is_dir(&self, fs: &Fs) -> Option { - self.followed_metadata(fs).map(FileMetadata::is_dir) - } } impl Hash for CachedPath { diff --git a/src/dts_resolver.rs b/src/dts_resolver.rs index 7ca71a8d0..65805284a 100644 --- a/src/dts_resolver.rs +++ b/src/dts_resolver.rs @@ -366,7 +366,7 @@ impl ResolverGeneric { fn dts_try_file(&self, base: &CachedPath, ext: &str, ctx: &mut Ctx) -> Option { let candidate = base.add_extension(ext, &self.cache); - if self.cache.is_file(&candidate, ctx) { Some(candidate) } else { None } + if self.is_file(&candidate, ctx) { Some(candidate) } else { None } } /// TS: `loadNodeModuleFromDirectoryWorker` @@ -376,7 +376,7 @@ impl ResolverGeneric { candidate: &CachedPath, ctx: &mut Ctx, ) -> ResolveResult { - if !self.cache.is_dir(candidate, ctx) { + if !self.is_dir(candidate, ctx) { return Ok(None); } @@ -444,7 +444,7 @@ impl ResolverGeneric { if let Some(path) = self.dts_resolve_as_file(expanded, &entry_path, ctx) { return Ok(Some(path)); } - if self.cache.is_dir(&entry_path, ctx) { + if self.is_dir(&entry_path, ctx) { let index = entry_path.push("index", &self.cache); if let Some(path) = self.dts_resolve_as_file(expanded, &index, ctx) { return Ok(Some(path)); @@ -479,7 +479,7 @@ impl ResolverGeneric { std::iter::successors(Some(directory.clone()), |cp| cp.parent(&self.cache)) { let nm = ancestor.push("node_modules", &self.cache); - if !self.cache.is_dir(&nm, ctx) { + if !self.is_dir(&nm, ctx) { continue; } @@ -494,7 +494,7 @@ impl ResolverGeneric { if priority_exts.contains(Extensions::DECLARATION) { let mangled = Self::dts_mangle_scoped_name(package_name); let at_types_dir = nm.push("@types", &self.cache); - if self.cache.is_dir(&at_types_dir, ctx) { + if self.is_dir(&at_types_dir, ctx) { let at_types_specifier = if rest.is_empty() { mangled.clone() } else { @@ -519,7 +519,7 @@ impl ResolverGeneric { std::iter::successors(Some(directory.clone()), |cp| cp.parent(&self.cache)) { let nm = ancestor.push("node_modules", &self.cache); - if !self.cache.is_dir(&nm, ctx) { + if !self.is_dir(&nm, ctx) { continue; } if let Some(path) = @@ -543,7 +543,7 @@ impl ResolverGeneric { let (package_name, rest) = Self::parse_package_specifier(specifier); let pkg_dir = nm_dir.normalize_with(package_name, &self.cache); - if !self.cache.is_dir(&pkg_dir, ctx) { + if !self.is_dir(&pkg_dir, ctx) { return Ok(None); } @@ -593,7 +593,7 @@ impl ResolverGeneric { if let Some(path) = self.dts_resolve_as_file(extensions, &candidate, ctx) { return Ok(Some(path)); } - if self.cache.is_dir(&candidate, ctx) { + if self.is_dir(&candidate, ctx) { return self.dts_resolve_as_directory(extensions, &candidate, ctx); } } @@ -608,7 +608,7 @@ impl ResolverGeneric { return Some(path); } // Fall back to original file if it exists - if self.cache.is_file(cached_path, ctx) { + if self.is_file(cached_path, ctx) { return Some(cached_path.clone()); } None @@ -669,7 +669,7 @@ impl ResolverGeneric { if let Some(path) = self.dts_resolve_as_file(extensions, &candidate, ctx) { return Ok(Some(path)); } - if self.cache.is_dir(&candidate, ctx) + if self.is_dir(&candidate, ctx) && let Some(path) = self.dts_resolve_as_directory(extensions, &candidate, ctx)? { diff --git a/src/lib.rs b/src/lib.rs index e365fe963..8fdf54dd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -349,7 +349,7 @@ impl ResolverGeneric { let mut last = None; // Go up directories when the querying path is not a directory let mut cp = cached_path.clone(); - if !self.cache.is_dir(&cp, ctx) + if !self.is_dir(&cp, ctx) && let Some(cv) = cp.parent(&self.cache) { cp = cv; @@ -378,6 +378,16 @@ impl ResolverGeneric { } } + /// [`Cache::is_file`] using this resolver's [`ResolveOptions::symlinks`] policy. + fn is_file(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { + self.cache.is_file(path, self.options.symlinks, ctx) + } + + /// [`Cache::is_dir`] using this resolver's [`ResolveOptions::symlinks`] policy. + fn is_dir(&self, path: &CachedPath, ctx: &mut Ctx) -> bool { + self.cache.is_dir(path, self.options.symlinks, ctx) + } + /// require(X) from module at path Y /// /// X: specifier @@ -789,14 +799,14 @@ impl ResolverGeneric { ctx: &mut Ctx, ) -> ResolveResult { if self.options.resolve_to_context { - return Ok(self.cache.is_dir(cached_path, ctx).then(|| cached_path.clone())); + return Ok(self.is_dir(cached_path, ctx).then(|| cached_path.clone())); } if !specifier.ends_with('/') && let Some(path) = self.load_as_file(cached_path, tsconfig, ctx)? { return Ok(Some(path)); } - if self.cache.is_dir(cached_path, ctx) + if self.is_dir(cached_path, ctx) && let Some(path) = self.load_as_directory(cached_path, tsconfig, ctx)? { return Ok(Some(path)); @@ -903,7 +913,7 @@ impl ResolverGeneric { if let Some(path) = self.load_browser_field_or_alias(cached_path, tsconfig, ctx)? { return Ok(Some(path)); } - if self.cache.is_file(cached_path, ctx) && self.check_restrictions(cached_path.path()) { + if self.is_file(cached_path, ctx) && self.check_restrictions(cached_path.path()) { return Ok(Some(cached_path.clone())); } Ok(None) @@ -932,7 +942,7 @@ impl ResolverGeneric { std::iter::successors(Some(cached_path.clone()), |cp| cp.parent(&self.cache)) { // Skip if /path/to/node_modules does not exist - if !self.cache.is_dir(&cached_path, ctx) { + if !self.is_dir(&cached_path, ctx) { continue; } @@ -947,7 +957,7 @@ impl ResolverGeneric { if !package_name.is_empty() { let cached_path = cached_path.normalize_with(package_name, &self.cache); // Try foo/node_modules/package_name - if self.cache.is_dir(&cached_path, ctx) { + if self.is_dir(&cached_path, ctx) { // a. LOAD_PACKAGE_EXPORTS(X, DIR) if let Some(path) = self.load_package_exports( specifier, @@ -967,7 +977,7 @@ impl ResolverGeneric { // i.e. `foo/node_modules/@scope` is not a directory for `foo/node_modules/@scope/package` if package_name.starts_with('@') && let Some(path) = cached_path.parent(&self.cache).as_ref() - && !self.cache.is_dir(path, ctx) + && !self.is_dir(path, ctx) { continue; } @@ -981,7 +991,7 @@ impl ResolverGeneric { let cached_path = cached_path.normalize_with(specifier, &self.cache); if self.options.resolve_to_context { - return Ok(self.cache.is_dir(&cached_path, ctx).then(|| cached_path.clone())); + return Ok(self.is_dir(&cached_path, ctx).then(|| cached_path.clone())); } // Only load the file if it is targeting a `X/sub/dir`. @@ -993,7 +1003,7 @@ impl ResolverGeneric { } // Otherwise just load the directory. // No modern package manager creates `node_modules/X.js`. - if self.cache.is_dir(&cached_path, ctx) { + if self.is_dir(&cached_path, ctx) { if let Some(path) = self.load_browser_field_or_alias(&cached_path, tsconfig, ctx)? { @@ -1106,13 +1116,13 @@ impl ResolverGeneric { ctx: &mut Ctx, ) -> Option { if module_name == "node_modules" { - cached_path.cached_node_modules(&self.cache, ctx) + cached_path.cached_node_modules(self.options.symlinks, &self.cache, ctx) } else if cached_path.path().components().next_back() == Some(Component::Normal(OsStr::new(module_name))) { Some(cached_path.clone()) } else { - cached_path.module_directory(module_name, &self.cache, ctx) + cached_path.module_directory(module_name, self.options.symlinks, &self.cache, ctx) } } @@ -1241,7 +1251,7 @@ impl ResolverGeneric { .as_ref() .is_some_and(|s| path.ends_with(Path::new(s))) { - return if self.cache.is_file(cached_path, ctx) { + return if self.is_file(cached_path, ctx) { if self.check_restrictions(cached_path.path()) { Ok(Some(cached_path.clone())) } else { @@ -1298,7 +1308,7 @@ impl ResolverGeneric { } } // Bail if path is module directory such as `ipaddr.js` - if !self.cache.is_file(cached_path, ctx) { + if !self.is_file(cached_path, ctx) { ctx.with_fully_specified(false); return Ok(None); } else if !self.check_restrictions(cached_path.path()) { @@ -1381,7 +1391,7 @@ impl ResolverGeneric { let cached_path = cached_path.normalize_with(package_name, &self.cache); // 3. If the folder at packageURL does not exist, then // 1. Continue the next loop iteration. - if self.cache.is_dir(&cached_path, ctx) { + if self.is_dir(&cached_path, ctx) { // 4. Let pjson be the result of READ_PACKAGE_JSON(packageURL). if let Some(package_json) = self.cache.get_package_json(&cached_path, &self.options, ctx)? @@ -1406,7 +1416,7 @@ impl ResolverGeneric { // 1. Return the URL resolution of main in packageURL. let cached_path = cached_path.normalize_with(main_field, &self.cache); - if self.cache.is_file(&cached_path, ctx) + if self.is_file(&cached_path, ctx) && self.check_restrictions(cached_path.path()) { return Ok(Some(cached_path)); diff --git a/src/tsconfig_resolver.rs b/src/tsconfig_resolver.rs index 6604e852d..81d5ae699 100644 --- a/src/tsconfig_resolver.rs +++ b/src/tsconfig_resolver.rs @@ -115,7 +115,7 @@ impl ResolverGeneric { 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, &mut ctx) { + if self.is_file(&tsconfig_path, &mut ctx) { match self.resolve_tsconfig(tsconfig_path.path()) { Ok(tsconfig) => Ok(Some(tsconfig)), // Skip unreadable tsconfig files (e.g. permission denied) @@ -354,7 +354,7 @@ impl ResolverGeneric { let Some(root_dirs) = &tsconfig.compiler_options.root_dirs else { return Ok(None) }; // Use the containing directory, not the file itself - let containing_directory = if self.cache.is_dir(cached_path, ctx) { + let containing_directory = if self.is_dir(cached_path, ctx) { cached_path.clone() } else { cached_path.parent(&self.cache).unwrap_or_else(|| cached_path.clone())