diff --git a/Cargo.lock b/Cargo.lock index 308fad0d7..b8ef47bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1397,6 +1397,7 @@ dependencies = [ "serde", "serde_json", "static_assertions", + "vfs", ] [[package]] @@ -2265,6 +2266,12 @@ version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "vfs" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded573c92d7b32013bdab82676be59f56106895e837504568f32804980ec7868" + [[package]] name = "walkdir" version = "2.3.3" diff --git a/crates/oxc_resolver/Cargo.toml b/crates/oxc_resolver/Cargo.toml index 7e76171d6..1304341af 100644 --- a/crates/oxc_resolver/Cargo.toml +++ b/crates/oxc_resolver/Cargo.toml @@ -18,6 +18,7 @@ serde_json = { workspace = true } [dev-dependencies] static_assertions = { workspace = true } criterion = { workspace = true } +vfs = "0.9.0" [[bench]] name = "resolver" diff --git a/crates/oxc_resolver/README.md b/crates/oxc_resolver/README.md index 0fbc8ff1c..c81bc1663 100644 --- a/crates/oxc_resolver/README.md +++ b/crates/oxc_resolver/README.md @@ -20,7 +20,7 @@ | | exportsFields | ["exports"] | A list of exports fields in description files | | ✅ | extensions | [".js", ".json", ".node"] | A list of extensions which should be tried for files | | | fallback | [] | Same as `alias`, but only used if default resolving fails | -| | fileSystem | | The file system which should be used | +| ✅ | fileSystem | | The file system which should be used | | | fullySpecified | false | Request passed to resolve is already fully specified and extensions or main files are not resolved for it (they are still resolved for internal requests) | | | mainFields | ["main"] | A list of main fields in description files | | ✅ | mainFiles | ["index"] | A list of main files in directories | diff --git a/crates/oxc_resolver/src/cache.rs b/crates/oxc_resolver/src/cache.rs new file mode 100644 index 000000000..b8151640d --- /dev/null +++ b/crates/oxc_resolver/src/cache.rs @@ -0,0 +1,56 @@ +use std::{path::Path, sync::Arc}; + +use dashmap::DashMap; + +use crate::{package_json::PackageJson, FileMetadata, FileSystem, ResolveError}; + +pub struct Cache { + fs: Fs, + cache: DashMap, Option>, + package_json_cache: DashMap, Result, ResolveError>>, +} + +impl Default for Cache { + fn default() -> Self { + Self { fs: Fs::default(), cache: DashMap::new(), package_json_cache: DashMap::new() } + } +} + +impl Cache { + pub fn new(fs: Fs) -> Self { + Self { fs, ..Self::default() } + } + + fn metadata_cached(&self, path: &Path) -> Option { + if let Some(result) = self.cache.get(path) { + return *result; + } + let file_metadata = self.fs.metadata(path).ok(); + self.cache.insert(path.to_path_buf().into_boxed_path(), file_metadata); + file_metadata + } + + pub fn is_file(&self, path: &Path) -> bool { + self.metadata_cached(path).is_some_and(|m| m.is_file) + } + + /// # Errors + /// + /// * [ResolveError::JSONError] + /// + /// # Panics + /// + /// * Failed to read the file (TODO: remove this) + pub fn read_package_json(&self, path: &Path) -> Result, ResolveError> { + if let Some(result) = self.package_json_cache.get(path) { + return result.value().clone(); + } + // TODO: handle file read error + let package_json_string = self.fs.read_to_string(path).unwrap(); + let result = PackageJson::parse(path.to_path_buf(), &package_json_string) + .map(Arc::new) + .map_err(|error| ResolveError::from_serde_json_error(path.to_path_buf(), &error)); + self.package_json_cache.insert(path.to_path_buf().into_boxed_path(), result.clone()); + result + } +} diff --git a/crates/oxc_resolver/src/file_system.rs b/crates/oxc_resolver/src/file_system.rs index c0c951d8a..527dfc952 100644 --- a/crates/oxc_resolver/src/file_system.rs +++ b/crates/oxc_resolver/src/file_system.rs @@ -1,46 +1,38 @@ -use std::{fs, path::Path, sync::Arc}; +use std::{fs, io, path::Path}; -use dashmap::DashMap; +pub trait FileSystem: Default + Send + Sync { + /// # Errors + /// + /// * Any [io::Error] + fn read_to_string>(&self, path: P) -> io::Result; -use crate::{package_json::PackageJson, ResolveError}; + /// # Errors + /// + /// * Any [io::Error] + fn metadata>(&self, path: P) -> io::Result; +} #[derive(Debug, Clone, Copy)] pub struct FileMetadata { - is_file: bool, + pub(crate) is_file: bool, } -/// [File System](https://doc.rust-lang.org/stable/std/fs/) with caching +impl FileMetadata { + pub fn new(is_file: bool) -> Self { + Self { is_file } + } +} + +/// Operating System #[derive(Default)] -pub struct FileSystem { - cache: DashMap, Option>, - package_json_cache: DashMap, Result, ResolveError>>, -} +pub struct FileSystemOs; -impl FileSystem { - /// - pub fn metadata(&self, path: &Path) -> Option { - if let Some(result) = self.cache.get(path) { - return *result; - } - let file_metadata = - fs::metadata(path).ok().map(|metadata| FileMetadata { is_file: metadata.is_file() }); - self.cache.insert(path.to_path_buf().into_boxed_path(), file_metadata); - file_metadata +impl FileSystem for FileSystemOs { + fn read_to_string>(&self, path: P) -> io::Result { + fs::read_to_string(path) } - pub fn is_file(&self, path: &Path) -> bool { - self.metadata(path).is_some_and(|m| m.is_file) - } - - pub fn read_package_json(&self, path: &Path) -> Result, ResolveError> { - if let Some(result) = self.package_json_cache.get(path) { - return result.value().clone(); - } - let package_json_string = fs::read_to_string(path).unwrap(); - let result = PackageJson::parse(path.to_path_buf(), &package_json_string) - .map(Arc::new) - .map_err(|error| ResolveError::from_serde_json_error(path.to_path_buf(), &error)); - self.package_json_cache.insert(path.to_path_buf().into_boxed_path(), result.clone()); - result + fn metadata>(&self, path: P) -> io::Result { + fs::metadata(path).map(|metadata| FileMetadata { is_file: metadata.is_file() }) } } diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index fe659dea0..ddd03901d 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -6,12 +6,14 @@ //! * Algorithm adapted from [Node.js Module Resolution Algorithm](https://nodejs.org/api/modules.html#all-together) and [cjs loader](https://github.com/nodejs/node/blob/main/lib/internal/modules/cjs/loader.js) //! * Some code adapted from [parcel-resolver](https://github.com/parcel-bundler/parcel/blob/v2/packages/utils/node-resolver-rs) +mod cache; mod error; mod file_system; mod options; mod package_json; mod path; mod request; +mod resolution; use std::{ ffi::OsStr, @@ -19,11 +21,13 @@ use std::{ }; pub use crate::{ + cache::Cache, error::{JSONError, ResolveError}, + file_system::{FileMetadata, FileSystem, FileSystemOs}, options::ResolveOptions, + resolution::Resolution, }; use crate::{ - file_system::FileSystem, path::PathUtil, request::{Request, RequestPath}, }; @@ -31,49 +35,27 @@ use crate::{ pub type ResolveResult = Result; type ResolveState = Result, ResolveError>; -#[derive(Debug, Clone, Eq, PartialEq)] -pub struct Resolution { - path: PathBuf, +/// Resolver with the current operating system as the file system +pub type Resolver = ResolverGeneric; - /// path query `?query`, contains `?`. - query: Option, - - /// path fragment `#query`, contains `#`. - fragment: Option, -} - -impl Resolution { - pub fn path(&self) -> &Path { - &self.path - } - - pub fn into_path_buf(self) -> PathBuf { - self.path - } - - pub fn query(&self) -> Option<&str> { - self.query.as_deref() - } - - pub fn fragment(&self) -> Option<&str> { - self.fragment.as_deref() - } -} - -pub struct Resolver { +pub struct ResolverGeneric { options: ResolveOptions, - fs: FileSystem, + cache: Cache, } -impl Default for Resolver { +impl Default for ResolverGeneric { fn default() -> Self { Self::new(ResolveOptions::default()) } } -impl Resolver { +impl ResolverGeneric { pub fn new(options: ResolveOptions) -> Self { - Self { options: options.sanitize(), fs: FileSystem::default() } + Self { options: options.sanitize(), cache: Cache::default() } + } + + pub fn new_with_file_system(options: ResolveOptions, file_system: Fs) -> Self { + Self { cache: Cache::new(file_system), ..Self::new(options) } } /// Resolve `request` at `path` @@ -160,7 +142,7 @@ impl Resolver { } // 1. If X is a file, load X as its file extension format. STOP - if self.fs.is_file(path) { + if self.cache.is_file(path) { return Ok(Some(path.to_path_buf())); } // 2. If X.js is a file, load X.js as JavaScript text. STOP @@ -168,7 +150,7 @@ impl Resolver { // 4. If X.node is a file, load X.node as binary addon. STOP for extension in &self.options.extensions { let path_with_extension = path.with_extension(extension); - if self.fs.is_file(&path_with_extension) { + if self.cache.is_file(&path_with_extension) { return Ok(Some(path_with_extension)); } } @@ -182,7 +164,7 @@ impl Resolver { // 3. If X/index.node is a file, load X/index.node as binary addon. STOP for extension in &self.options.extensions { let index_path = path.join("index").with_extension(extension); - if self.fs.is_file(&index_path) { + if self.cache.is_file(&index_path) { return Ok(Some(index_path)); } } @@ -192,9 +174,9 @@ impl Resolver { fn load_as_directory(&self, path: &Path) -> ResolveState { // 1. If X/package.json is a file, let package_json_path = path.join("package.json"); - if self.fs.is_file(&package_json_path) { + if self.cache.is_file(&package_json_path) { // a. Parse X/package.json, and look for "main" field. - let package_json = self.fs.read_package_json(&package_json_path)?; + let package_json = self.cache.read_package_json(&package_json_path)?; // b. If "main" is a falsy value, GOTO 2. if let Some(main_field) = &package_json.main { // c. let M = X + (json main field) @@ -251,8 +233,8 @@ impl Resolver { fn load_package_self(&self, path: &Path, request_str: &str) -> ResolveState { for dir in path.ancestors() { let package_json_path = dir.join("package.json"); - if self.fs.is_file(&package_json_path) { - let package_json = self.fs.read_package_json(&package_json_path)?; + if self.cache.is_file(&package_json_path) { + let package_json = self.cache.read_package_json(&package_json_path)?; if let Some(request_str) = package_json.resolve_request(path, request_str, &self.options.extensions)? { @@ -282,7 +264,7 @@ impl Resolver { }; for extension in extensions { let path_with_extension = path.with_extension(extension); - if self.fs.is_file(&path_with_extension) { + if self.cache.is_file(&path_with_extension) { return Ok(Some(path_with_extension)); } } diff --git a/crates/oxc_resolver/src/resolution.rs b/crates/oxc_resolver/src/resolution.rs new file mode 100644 index 000000000..56b599eb2 --- /dev/null +++ b/crates/oxc_resolver/src/resolution.rs @@ -0,0 +1,30 @@ +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct Resolution { + pub(crate) path: PathBuf, + + /// path query `?query`, contains `?`. + pub(crate) query: Option, + + /// path fragment `#query`, contains `#`. + pub(crate) fragment: Option, +} + +impl Resolution { + pub fn path(&self) -> &Path { + &self.path + } + + pub fn into_path_buf(self) -> PathBuf { + self.path + } + + pub fn query(&self) -> Option<&str> { + self.query.as_deref() + } + + pub fn fragment(&self) -> Option<&str> { + self.fragment.as_deref() + } +} diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs new file mode 100644 index 000000000..9d870c52b --- /dev/null +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs @@ -0,0 +1,27 @@ +//! + +use oxc_resolver::{ResolveOptions, ResolverGeneric}; + +use crate::MemoryFS; + +#[test] +fn alias() { + 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", ""), + ("/d/dir/.empty", ""), + ("/e/index", ""), + ("/e/anotherDir/index", ""), + ("/e/dir/file", ""), + ]); + let options = ResolveOptions::default(); + let resolver = ResolverGeneric::::new_with_file_system(options, file_system); + assert!(resolver.resolve("/a/index", ".").is_ok()); +} diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs index 21b53e972..70aa8c438 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/mod.rs @@ -1,3 +1,4 @@ +mod alias; mod browser_field; mod extension_alias; mod extensions; diff --git a/crates/oxc_resolver/tests/memory_fs.rs b/crates/oxc_resolver/tests/memory_fs.rs new file mode 100644 index 000000000..8997b2ca7 --- /dev/null +++ b/crates/oxc_resolver/tests/memory_fs.rs @@ -0,0 +1,60 @@ +use std::{io, path::Path}; + +use oxc_resolver::{FileMetadata, FileSystem}; + +pub struct MemoryFS { + fs: vfs::MemoryFS, +} + +impl Default for MemoryFS { + fn default() -> Self { + let fs = vfs::MemoryFS::new(); + Self { fs } + } +} + +impl MemoryFS { + /// # Panics + /// + /// * Fails to create directory + /// * Fails to write file + pub fn new(data: &[(&'static str, &'static str)]) -> Self { + use vfs::FileSystem; + let fs = vfs::MemoryFS::default(); + for (path, string) in data { + // Create all parent directories + for path in Path::new(path).ancestors().collect::>().iter().rev() { + let path = path.to_string_lossy(); + if !fs.exists(path.as_ref()).unwrap() { + fs.create_dir(path.as_ref()).unwrap(); + } + } + let mut file = fs.create_file(path).unwrap(); + file.write_all(string.as_bytes()).unwrap(); + } + Self { fs } + } +} + +impl FileSystem for MemoryFS { + fn read_to_string>(&self, path: P) -> io::Result { + use vfs::FileSystem; + let mut file = self + .fs + .open_file(path.as_ref().to_string_lossy().as_ref()) + .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; + let mut buffer = String::new(); + file.read_to_string(&mut buffer).unwrap(); + Ok(buffer) + } + + fn metadata>(&self, path: P) -> io::Result { + use vfs::FileSystem; + let metadata = self + .fs + .metadata(path.as_ref().to_string_lossy().as_ref()) + .map_err(|err| io::Error::new(io::ErrorKind::NotFound, err))?; + let is_file = metadata.file_type == vfs::VfsFileType::File; + Ok(FileMetadata::new(is_file)) + } +} diff --git a/crates/oxc_resolver/tests/mod.rs b/crates/oxc_resolver/tests/mod.rs index 3f2f0c7a1..8d7d45340 100644 --- a/crates/oxc_resolver/tests/mod.rs +++ b/crates/oxc_resolver/tests/mod.rs @@ -1,7 +1,9 @@ mod enhanced_resolve; +mod memory_fs; use std::{env, sync::Arc, thread}; +pub(crate) use memory_fs::MemoryFS; use oxc_resolver::Resolver; #[test]