Skip to content
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ node_modules
fuzz/Cargo.lock
artifacts
bindings
optimization-artifacts/
41 changes: 37 additions & 4 deletions src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ use futures::future::BoxFuture;
use rustc_hash::FxHasher;
use tokio::sync::OnceCell as OnceLock;

#[cfg(unix)]
use crate::path::path_parent_unix;
use crate::{
context::ResolveContext as Ctx,
package_json::{off_to_location, PackageJson},
path::PathUtil,
path::{path_join_preallocated, PathUtil},
FileMetadata, FileSystem, JSONError, ResolveError, ResolveOptions, TsConfig,
};

Expand Down Expand Up @@ -59,6 +61,9 @@ impl<Fs: Send + Sync + FileSystem> Cache<Fs> {
if let Some(cache_entry) = self.paths.get((hash, path).borrow() as &dyn CacheKey) {
return cache_entry.clone();
}
#[cfg(unix)]
let parent = path_parent_unix(path).map(|p| self.value(p));
#[cfg(not(unix))]
let parent = path.parent().map(|p| self.value(p));
let data = CachedPath(Arc::new(CachedPathImpl::new(
hash,
Expand Down Expand Up @@ -249,6 +254,22 @@ impl CachedPathImpl {
}
if let Some(parent) = self.parent() {
let parent_path = parent.realpath(fs).await?;
// Fast path: when no symlinks were found anywhere up the chain
// (the common case in resolver inputs), `parent_path` is the
// same bytes as `parent.path`. Cache `None` instead of an
// owned copy of `self.path` so the get_or_try_init body
// sidesteps the `normalize_with` allocation and the outer
// `unwrap_or_else` falls through to `self.path` directly.
#[cfg(unix)]
let parent_unchanged = {
use std::os::unix::ffi::OsStrExt;
parent_path.as_os_str().as_bytes() == parent.path.as_os_str().as_bytes()
};
#[cfg(not(unix))]
let parent_unchanged = parent_path.as_path() == &*parent.path;
if parent_unchanged {
return Ok(None);
}
return Ok(Some(
Comment on lines +269 to 273
parent_path.normalize_with(self.path.strip_prefix(&parent.path).unwrap()),
));
Expand All @@ -268,7 +289,7 @@ impl CachedPathImpl {
cache: &Cache<Fs>,
ctx: &mut Ctx,
) -> Option<CachedPath> {
let cached_path = cache.value(&self.path.join(module_name));
let cached_path = cache.value(&path_join_preallocated(&self.path, module_name));
cached_path
.is_dir(&cache.fs, ctx)
.await
Expand Down Expand Up @@ -349,7 +370,7 @@ impl CachedPathImpl {
let result = self
.package_json
.get_or_try_init(|| async {
let package_json_path = self.path.join("package.json");
let package_json_path = path_join_preallocated(&self.path, "package.json");
let Ok(package_json_string) = fs.read(&package_json_path).await else {
return Ok(None);
};
Expand Down Expand Up @@ -427,7 +448,19 @@ impl Hash for dyn CacheKey + '_ {

impl PartialEq for dyn CacheKey + '_ {
fn eq(&self, other: &Self) -> bool {
self.tuple().1 == other.tuple().1
let a = self.tuple().1;
let b = other.tuple().1;
// On Unix, raw byte compare matches `Path::eq` semantics and skips
// the std `Components` iteration cost. Mirrors the hash side already
// bytes-based in `Cache::value`.
#[cfg(unix)]
{
a.as_os_str().as_bytes() == b.as_os_str().as_bytes()
}
#[cfg(not(unix))]
{
a == b
}
}
}

Expand Down
33 changes: 17 additions & 16 deletions src/file_system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,19 +147,26 @@ impl FileSystemOs {
#[cfg(not(target_arch = "wasm32"))]
#[async_trait::async_trait]
impl FileSystem for FileSystemOs {
// The bulk of resolver work is filesystem stat/read operations whose syscall
// cost is microseconds. Calling them through `tokio::fs` forces a
// `spawn_blocking` + semaphore acquire + park/unpark per call, which the
// single-thread bench shows costs ~20M Ir per iteration in tokio runtime
// overhead alone. Use sync std::fs here; the trait still exposes an `async fn`
// so callers can await as before. Other Rust resolvers (swc, oxc) make the
// same tradeoff for the same reason.
async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
cfg_if! {
if #[cfg(feature = "yarn_pnp")] {
if self.options.enable_pnp {
return match VPath::from(path)? {
VPath::Zip(info) => self.pnp_lru.read(info.physical_base_path(), info.zip_path),
VPath::Virtual(info) => tokio::fs::read(info.physical_base_path()).await,
VPath::Native(path) => tokio::fs::read(&path).await,
VPath::Virtual(info) => fs::read(info.physical_base_path()),
VPath::Native(path) => fs::read(&path),
}
}
}}

tokio::fs::read(path).await
fs::read(path)
}

async fn read_to_string(&self, path: &Path) -> io::Result<String> {
Expand All @@ -168,13 +175,13 @@ impl FileSystem for FileSystemOs {
if self.options.enable_pnp {
return match VPath::from(path)? {
VPath::Zip(info) => self.pnp_lru.read_to_string(info.physical_base_path(), info.zip_path),
VPath::Virtual(info) => tokio::fs::read_to_string(info.physical_base_path()).await,
VPath::Native(path) => tokio::fs::read_to_string(&path).await,
VPath::Virtual(info) => fs::read_to_string(info.physical_base_path()),
VPath::Native(path) => fs::read_to_string(&path),
}
}
}
}
tokio::fs::read_to_string(path).await
fs::read_to_string(path)
}

async fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
Expand All @@ -186,24 +193,18 @@ impl FileSystem for FileSystemOs {
.pnp_lru
.file_type(info.physical_base_path(), info.zip_path)
.map(FileMetadata::from),
VPath::Virtual(info) => {
tokio::fs::metadata(info.physical_base_path())
.await
.map(FileMetadata::from)
}
VPath::Native(path) => tokio::fs::metadata(path).await.map(FileMetadata::from),
VPath::Virtual(info) => fs::metadata(info.physical_base_path()).map(FileMetadata::from),
VPath::Native(path) => fs::metadata(path).map(FileMetadata::from),
}
}
}
}

tokio::fs::metadata(path).await.map(FileMetadata::from)
fs::metadata(path).map(FileMetadata::from)
}

async fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
tokio::fs::symlink_metadata(path)
.await
.map(FileMetadata::from)
fs::symlink_metadata(path).map(FileMetadata::from)
}

async fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
Expand Down
101 changes: 84 additions & 17 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,50 @@ use crate::{

type ResolveResult = Result<Option<CachedPath>, ResolveError>;

/// Classifies a specifier into the dispatch bucket used by
/// [`ResolverGeneric::require_without_parse`].
///
/// Avoids the std `Path::Components` walk for the dispatch decision, which
/// fires on every resolve. The bench `single-thread` case triggers this
/// ~880 times per iteration with all-cold caches.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpecifierKind {
Absolute,
Relative,
Hash,
Other,
}

#[inline]
fn specifier_kind(specifier: &str) -> SpecifierKind {
let bytes = specifier.as_bytes();
match bytes {
[] => SpecifierKind::Other,
[b'/', ..] => SpecifierKind::Absolute,
#[cfg(windows)]
[b'\\', ..] => SpecifierKind::Absolute,
[b'.', b'.'] | [b'.', b'.', b'/', ..] => SpecifierKind::Relative,
#[cfg(windows)]
[b'.', b'.', b'\\', ..] => SpecifierKind::Relative,
[b'.'] | [b'.', b'/', ..] => SpecifierKind::Relative,
#[cfg(windows)]
[b'.', b'\\', ..] => SpecifierKind::Relative,
[b'#', ..] => SpecifierKind::Hash,
_ => {
#[cfg(windows)]
{
if matches!(
Path::new(specifier).components().next(),
Some(Component::Prefix(_))
) {
return SpecifierKind::Absolute;
}
}
SpecifierKind::Other
}
}
}

/// Context returned from the [Resolver::resolve_with_context] API
#[derive(Debug, Default, Clone)]
pub struct ResolveContext {
Expand Down Expand Up @@ -336,20 +380,14 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
return Ok(path);
}

let result = match Path::new(specifier).components().next() {
let result = match specifier_kind(specifier) {
// 2. If X begins with '/'
Some(Component::RootDir | Component::Prefix(_)) => {
self.require_absolute(cached_path, specifier, ctx).await
}
// 3. If X begins with './' or '/' or '../'
Some(Component::CurDir | Component::ParentDir) => {
self.require_relative(cached_path, specifier, ctx).await
}
SpecifierKind::Absolute => self.require_absolute(cached_path, specifier, ctx).await,
// 3. If X begins with './' or '../' (or is "." / "..")
SpecifierKind::Relative => self.require_relative(cached_path, specifier, ctx).await,
// 4. If X begins with '#'
Some(Component::Normal(_)) if specifier.as_bytes()[0] == b'#' => {
self.require_hash(cached_path, specifier, ctx).await
}
_ => {
SpecifierKind::Hash => self.require_hash(cached_path, specifier, ctx).await,
SpecifierKind::Other => {
// 1. If X is a core module,
// a. return the core module
// b. STOP
Expand Down Expand Up @@ -1096,9 +1134,18 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
// 5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
// `package.json` "exports", ["node", "require"]) defined in the ESM resolver.
// Note: The subpath is not prepended with a dot on purpose
// Skip the `format!` alloc when subpath is empty (the common bare-specifier
// case like `@scope/pkg`). When non-empty, prepend once before the loop.
let prefixed_owned;
let prefixed: &str = if subpath.is_empty() {
"."
} else {
prefixed_owned = format!(".{subpath}");
&prefixed_owned
};
for exports in package_json.exports_fields(&self.options.exports_fields) {
if let Some(path) = self
.package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx)
.package_exports_resolve(cached_path.path(), prefixed, exports, ctx)
.await?
{
// 6. RESOLVE_ESM_MATCH(MATCH)
Expand Down Expand Up @@ -1136,9 +1183,16 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
let package_url = package_json.directory();
// Note: The subpath is not prepended with a dot on purpose
// because `package_exports_resolve` matches subpath without the leading dot.
let prefixed_owned;
let prefixed: &str = if subpath.is_empty() {
"."
} else {
prefixed_owned = format!(".{subpath}");
&prefixed_owned
};
for exports in package_json.exports_fields(&self.options.exports_fields) {
if let Some(cached_path) = self
.package_exports_resolve(package_url, &format!(".{subpath}"), exports, ctx)
.package_exports_resolve(package_url, prefixed, exports, ctx)
.await?
{
// 6. RESOLVE_ESM_MATCH(MATCH)
Expand Down Expand Up @@ -1707,9 +1761,16 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
.package_json(&self.cache.fs, &self.options, ctx)
.await?
{
let prefixed_owned;
let prefixed: &str = if subpath.is_empty() {
"."
} else {
prefixed_owned = format!(".{subpath}");
&prefixed_owned
};
for exports in package_json.exports_fields(&self.options.exports_fields) {
if let Some(path) = self
.package_exports_resolve(cached_path.path(), &format!(".{subpath}"), exports, ctx)
.package_exports_resolve(cached_path.path(), prefixed, exports, ctx)
.await?
{
return Ok(Some(path));
Expand All @@ -1727,9 +1788,15 @@ impl<Fs: FileSystem + Send + Sync> ResolverGeneric<Fs> {
}
}
}
let subpath = format!(".{subpath}");
let subpath_owned;
let subpath: &str = if subpath.is_empty() {
"."
} else {
subpath_owned = format!(".{subpath}");
&subpath_owned
};
ctx.with_fully_specified(false);
self.require(&cached_path, &subpath, ctx).await.map(Some)
self.require(&cached_path, subpath, ctx).await.map(Some)
}

/// PACKAGE_EXPORTS_RESOLVE(packageURL, subpath, exports, conditions)
Expand Down
Loading
Loading