diff --git a/crates/oxc_resolver/README.md b/crates/oxc_resolver/README.md index c81bc1663..393aa9a00 100644 --- a/crates/oxc_resolver/README.md +++ b/crates/oxc_resolver/README.md @@ -9,7 +9,7 @@ | Done | Field | Default | Description | |------|------------------|-----------------------------| --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| | alias | [] | A list of module alias configurations or an object which maps key to value | +| ✅ | alias | [] | A list of module alias configurations or an object which maps key to value | | ✅ | aliasFields | [] | A list of alias fields in description files | | ✅ | extensionAlias | {} | An object which maps extension to extension aliases | | | cachePredicate | function() { return true }; | A function which decides whether a request should be cached or not. An object is passed to the function with `path` and `request` properties. | @@ -24,7 +24,7 @@ | | 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 | -| | modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name | +| ✅ | modules | ["node_modules"] | A list of directories to resolve modules from, can be absolute path or folder name | | | plugins | [] | A list of additional resolve plugins which should be applied | | | resolver | undefined | A prepared Resolver to which the plugins are attached | | | resolveToContext | false | Resolve to a context instead of a file | @@ -41,7 +41,7 @@ Tests ported from [enhanced-resolve](https://github.com/webpack/enhanced-resolve - [ ] CachedInputFileSystem.test.js - [ ] SyncAsyncFileSystemDecorator.test.js -- [ ] alias.test.js +- [x] alias.test.js (partially done) - [x] browserField.test.js (reading the browser field is currently static - not read from the `browserField` option) - [ ] dependencies.test.js - [ ] exportsField.test.js diff --git a/crates/oxc_resolver/src/error.rs b/crates/oxc_resolver/src/error.rs index 43a918c04..51f7a524f 100644 --- a/crates/oxc_resolver/src/error.rs +++ b/crates/oxc_resolver/src/error.rs @@ -26,6 +26,9 @@ pub enum ResolveError { /// All of the aliased extension are not found ExtensionAlias, + /// All of the aliases are not found + Alias(String), + /// JSON parse error JSON(JSONError), } diff --git a/crates/oxc_resolver/src/lib.rs b/crates/oxc_resolver/src/lib.rs index ddd03901d..c0f24aa7f 100644 --- a/crates/oxc_resolver/src/lib.rs +++ b/crates/oxc_resolver/src/lib.rs @@ -16,6 +16,7 @@ mod request; mod resolution; use std::{ + borrow::Cow, ffi::OsStr, path::{Path, PathBuf}, }; @@ -24,7 +25,7 @@ pub use crate::{ cache::Cache, error::{JSONError, ResolveError}, file_system::{FileMetadata, FileSystem, FileSystemOs}, - options::ResolveOptions, + options::{AliasValue, ResolveOptions}, resolution::Resolution, }; use crate::{ @@ -66,10 +67,15 @@ impl ResolverGeneric { pub fn resolve>( &self, path: P, - request: &str, + request_str: &str, ) -> Result { - let request = Request::parse(request).map_err(ResolveError::Request)?; - let path = self.require(path.as_ref(), &request)?; + let path = path.as_ref(); + let request = Request::parse(request_str).map_err(ResolveError::Request)?; + let path = if let Some(path) = self.load_alias(path, request.path.as_str())? { + path + } else { + self.require(path, &request)? + }; Ok(Resolution { path, query: request.query.map(ToString::to_string), @@ -114,6 +120,9 @@ impl ResolverGeneric { return Err(ResolveError::NotFound(path.into_boxed_path())); } // 4. If X begins with '#' + RequestPath::Hash(hash_path) => { + request_str = hash_path; + } // a. LOAD_PACKAGE_IMPORTS(X, dirname(Y)) RequestPath::Module(module_path) => { request_str = module_path; @@ -159,13 +168,19 @@ impl ResolverGeneric { #[allow(clippy::unnecessary_wraps)] fn load_index(&self, path: &Path) -> ResolveState { - // 1. If X/index.js is a file, load X/index.js as JavaScript text. STOP - // 2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP - // 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.cache.is_file(&index_path) { - return Ok(Some(index_path)); + for main_field in &self.options.main_files { + let main_path = path.join(main_field); + if !self.options.enforce_extension && self.cache.is_file(&main_path) { + return Ok(Some(main_path)); + } + // 1. If X/index.js is a file, load X/index.js as JavaScript text. STOP + // 2. If X/index.json is a file, parse X/index.json to a JavaScript object. STOP + // 3. If X/index.node is a file, load X/index.node as binary addon. STOP + for extension in &self.options.extensions { + let main_path_with_extension = main_path.with_extension(extension); + if self.cache.is_file(&main_path_with_extension) { + return Ok(Some(main_path_with_extension)); + } } } Ok(None) @@ -200,7 +215,7 @@ impl ResolverGeneric { fn load_node_modules(&self, start: &Path, request_str: &str) -> ResolveState { // 1. let DIRS = NODE_MODULES_PATHS(START) - let dirs = Self::node_module_paths(start); + let dirs = self.node_module_paths(start); // 2. for each DIR in DIRS: for node_module_path in dirs { let node_module_path = node_module_path.join(request_str); @@ -224,10 +239,9 @@ impl ResolverGeneric { Ok(None) } - fn node_module_paths(path: &Path) -> impl Iterator + '_ { + fn node_module_paths<'a>(&'a self, path: &'a Path) -> impl Iterator + 'a { path.ancestors() - .filter(|path| path.file_name().is_some_and(|name| name != "node_modules")) - .map(|path| path.join("node_modules")) + .flat_map(|path| self.options.modules.iter().map(|module| path.join(module))) } fn load_package_self(&self, path: &Path, request_str: &str) -> ResolveState { @@ -247,6 +261,37 @@ impl ResolverGeneric { Ok(None) } + fn load_alias(&self, path: &Path, request_str: &str) -> ResolveState { + for (alias, requests) in &self.options.alias { + let exact_match = alias.strip_prefix(request_str).is_some_and(|c| c == "$"); + if request_str.starts_with(alias) || exact_match { + for request in requests { + match request { + AliasValue::Path(new_request) => { + let new_request = if exact_match { + Cow::Borrowed(new_request) + } else { + Cow::Owned(request_str.replacen(alias, new_request, 1)) + }; + let new_request = + Request::parse(&new_request).map_err(ResolveError::Request)?; + match self.require(path, &new_request) { + Err(ResolveError::NotFound(_)) => { /* noop */ } + Ok(path) => return Ok(Some(path)), + Err(err) => return Err(err), + } + } + AliasValue::Ignore => { + return Err(ResolveError::Ignored(path.join(alias).into_boxed_path())); + } + } + } + return Err(ResolveError::Alias(alias.clone())); + } + } + Ok(None) + } + /// Given an extension alias map `{".js": [".ts", "js"]}`, /// load the mapping instead of the provided extension /// diff --git a/crates/oxc_resolver/src/options.rs b/crates/oxc_resolver/src/options.rs index 8c78bafc6..13eda913b 100644 --- a/crates/oxc_resolver/src/options.rs +++ b/crates/oxc_resolver/src/options.rs @@ -1,5 +1,17 @@ +#[derive(Debug, Clone)] +pub enum AliasValue { + /// The path value + Path(String), + + /// The `false` value + Ignore, +} + #[derive(Debug, Clone)] pub struct ResolveOptions { + /// A list of module alias configurations or an object which maps key to value + pub alias: Vec<(String, Vec)>, + /// A list of alias fields in description files. /// Specify a field, such as `browser`, to be parsed according to [this specification](https://github.com/defunctzombie/package-browser-field-spec). /// @@ -25,16 +37,23 @@ pub struct ResolveOptions { /// /// Default `["index"]` pub main_files: Vec, + + /// A list of directories to resolve modules from, can be absolute path or folder name + /// + /// Default `["node_modules"]` + pub modules: Vec, } impl Default for ResolveOptions { fn default() -> Self { Self { + alias: vec![], alias_fields: vec![], extension_alias: vec![], enforce_extension: false, extensions: vec![".js".into(), ".json".into(), ".node".into()], main_files: vec!["index".into()], + modules: vec!["node_modules".into()], } } } diff --git a/crates/oxc_resolver/src/request.rs b/crates/oxc_resolver/src/request.rs index b877c1757..85ecbfb95 100644 --- a/crates/oxc_resolver/src/request.rs +++ b/crates/oxc_resolver/src/request.rs @@ -16,21 +16,53 @@ pub enum RequestPath<'a> { Absolute(&'a str), /// `./path`, `../path` Relative(&'a str), + /// `#path` + Hash(&'a str), /// `path`, `@scope/path` Module(&'a str), } +impl<'a> RequestPath<'a> { + pub fn as_str(&self) -> &str { + match self { + Self::Absolute(s) | Self::Relative(s) | Self::Hash(s) | Self::Module(s) => s, + } + } +} + impl<'a> Request<'a> { pub fn parse(request: &'a str) -> Result, RequestError> { - let (request, query, fragment) = Self::parse_query_framgment(request); - RequestPath::parse(request).map(|path| Self { path, query, fragment }) + if request.is_empty() { + return Err(RequestError::Empty); + } + + let (path, query, fragment) = match request.as_bytes()[0] { + b'/' => { + let (path, query, fragment) = Self::parse_query_framgment(request, 1); + (RequestPath::Absolute(path), query, fragment) + } + b'.' => { + let (path, query, fragment) = Self::parse_query_framgment(request, 1); + (RequestPath::Relative(path), query, fragment) + } + b'#' => { + let (path, query, fragment) = Self::parse_query_framgment(request, 1); + (RequestPath::Hash(path), query, fragment) + } + _ => { + let (path, query, fragment) = Self::parse_query_framgment(request, 0); + (RequestPath::Module(path), query, fragment) + } + }; + + Ok(Self { path, query, fragment }) } - fn parse_query_framgment(request: &str) -> (&str, Option<&str>, Option<&str>) { + fn parse_query_framgment(request: &str, skip: usize) -> (&str, Option<&str>, Option<&str>) { let mut query_start: Option = None; let mut fragment_start: Option = None; - for (i, c) in request.as_bytes().iter().enumerate() { + for (i, c) in request.as_bytes().iter().enumerate().skip(skip) { match *c { b'?' => query_start = Some(i), b'#' => fragment_start = Some(i), @@ -52,17 +84,6 @@ impl<'a> Request<'a> { } } -impl<'a> RequestPath<'a> { - fn parse(request: &'a str) -> Result { - match request.chars().next() { - Some('/') => Ok(Self::Absolute(request)), - Some('.') => Ok(Self::Relative(request)), - Some(_) => Ok(Self::Module(request)), - _ => Err(RequestError::Empty), - } - } -} - #[cfg(test)] mod tests { use super::{Request, RequestError, RequestPath}; @@ -81,18 +102,38 @@ mod tests { #[test] fn absolute() -> Result<(), RequestError> { - let request = "/test"; + let request = "/test?#"; let parsed = Request::parse(request)?; - assert_eq!(parsed.path, RequestPath::Absolute(request)); + assert_eq!(parsed.path, RequestPath::Absolute("/test")); + assert_eq!(parsed.query, Some("?")); + assert_eq!(parsed.fragment, Some("#")); Ok(()) } #[test] - fn parse_relative() -> Result<(), RequestError> { - let requests = ["./test", "../test", "../../test"]; + fn relative() -> Result<(), RequestError> { + let requests = ["./test", "../test?#", "../../test?#"]; for request in requests { - let parsed = Request::parse(request)?; + let mut r = request.to_string(); + r.push_str("?#"); + let parsed = Request::parse(&r)?; assert_eq!(parsed.path, RequestPath::Relative(request)); + assert_eq!(parsed.query, Some("?")); + assert_eq!(parsed.fragment, Some("#")); + } + Ok(()) + } + + #[test] + fn hash() -> Result<(), RequestError> { + let requests = ["#", "#path"]; + for request in requests { + let mut r = request.to_string(); + r.push_str("?#"); + let parsed = Request::parse(&r)?; + assert_eq!(parsed.path, RequestPath::Hash(request)); + assert_eq!(parsed.query, Some("?")); + assert_eq!(parsed.fragment, Some("#")); } Ok(()) } @@ -101,31 +142,38 @@ mod tests { fn module() -> Result<(), RequestError> { let requests = ["module"]; for request in requests { - let parsed = Request::parse(request)?; + let mut r = request.to_string(); + r.push_str("?#"); + let parsed = Request::parse(&r)?; assert_eq!(parsed.path, RequestPath::Module(request)); + assert_eq!(parsed.query, Some("?")); + assert_eq!(parsed.fragment, Some("#")); } Ok(()) } #[test] fn query_fragment() -> Result<(), RequestError> { - assert_eq!(Request::parse("a?")?.query, Some("?")); - assert_eq!(Request::parse("a?b")?.query, Some("?b")); + let data = [ + ("a?", Some("?"), None), + ("a?query", Some("?query"), None), + ("a#", None, Some("#")), + ("a#fragment", None, Some("#fragment")), + ("a?#", Some("?"), Some("#")), + ("a?#fragment", Some("?"), Some("#fragment")), + ("a?query#", Some("?query"), Some("#")), + ("a?query#fragment", Some("?query"), Some("#fragment")), + ("a#fragment?", Some("?"), Some("#fragment")), + ("a#fragment?query", Some("?query"), Some("#fragment")), + ]; - assert_eq!(Request::parse("a#")?.fragment, Some("#")); - assert_eq!(Request::parse("a#b")?.fragment, Some("#b")); + for (request_str, query, fragment) in data { + let request = Request::parse(request_str)?; + assert_eq!(request.path.as_str(), "a", "{request_str}"); + assert_eq!(request.query, query, "{request_str}"); + assert_eq!(request.fragment, fragment, "{request_str}"); + } - let request = Request::parse("a?#")?; - assert_eq!(request.query, Some("?")); - assert_eq!(request.fragment, Some("#")); - - let request = Request::parse("a?b#c")?; - assert_eq!(request.query, Some("?b")); - assert_eq!(request.fragment, Some("#c")); - - let request = Request::parse("a#b?c")?; - assert_eq!(request.query, Some("?c")); - assert_eq!(request.fragment, Some("#b")); Ok(()) } } diff --git a/crates/oxc_resolver/src/resolution.rs b/crates/oxc_resolver/src/resolution.rs index 56b599eb2..b64bfa471 100644 --- a/crates/oxc_resolver/src/resolution.rs +++ b/crates/oxc_resolver/src/resolution.rs @@ -12,19 +12,35 @@ pub struct Resolution { } impl Resolution { + /// Returns the path without query and fragment pub fn path(&self) -> &Path { &self.path } + /// Returns the path without query and fragment pub fn into_path_buf(self) -> PathBuf { self.path } + /// Returns the path query `?query`, contains the leading `?` pub fn query(&self) -> Option<&str> { self.query.as_deref() } + /// Returns the path fragment `#fragment`, contains the leading `#` pub fn fragment(&self) -> Option<&str> { self.fragment.as_deref() } + + /// Returns the full path with query and fragment + pub fn full_path(self) -> PathBuf { + let mut path = self.path.into_os_string(); + if let Some(query) = &self.query { + path.push(query); + } + if let Some(fragment) = &self.fragment { + path.push(fragment); + } + PathBuf::from(path) + } } diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs index 9d870c52b..170bb1dca 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/alias.rs @@ -1,11 +1,16 @@ //! -use oxc_resolver::{ResolveOptions, ResolverGeneric}; +use std::path::Path; + +use oxc_resolver::{AliasValue, ResolveError, ResolveOptions, Resolver, ResolverGeneric}; use crate::MemoryFS; #[test] -fn alias() { +#[cfg(not(target_os = "windows"))] // MemoryFS's path separator is always `/` so the test will not pass in windows. +fn alias() -> Result<(), ResolveError> { + let f = Path::new("/"); + let file_system = MemoryFS::new(&[ ("/a/index", ""), ("/a/dir/index", ""), @@ -21,7 +26,91 @@ fn alias() { ("/e/anotherDir/index", ""), ("/e/dir/file", ""), ]); - let options = ResolveOptions::default(); + + #[rustfmt::skip] + let options = ResolveOptions { + alias: vec![ + ("aliasA".into(), vec![AliasValue::Path("a".into())]), + ("b$".into(), vec![AliasValue::Path("a/index".into())]), + ("c$".into(), vec![AliasValue::Path("/a/index".into())]), + ("multiAlias".into(), vec![AliasValue::Path("b".into()), AliasValue::Path("c".into()), AliasValue::Path("d".into()), AliasValue::Path("e".into()), AliasValue::Path("a".into())]), + ("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())]), + // alias configuration should work + ("#".into(), vec![AliasValue::Path("/c/dir".into())]), + ("@".into(), vec![AliasValue::Path("/c/dir".into())]), + ("ignored".into(), vec![AliasValue::Ignore]), + ], + modules: vec!["/".into()], + ..ResolveOptions::default() + }; + let resolver = ResolverGeneric::::new_with_file_system(options, file_system); - assert!(resolver.resolve("/a/index", ".").is_ok()); + + #[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 a recursive aliased module 1", "recursive", "/recursive/dir/index"), + ("should resolve a recursive aliased module 2", "recursive/index", "/recursive/dir/index"), + // TODO recursive + // ("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 1", "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"), + // TODO aliased file + // ("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 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"), + ]; + + for (comment, request, expected) in pass { + let resolution = resolver.resolve(f, request)?; + assert_eq!(resolution.full_path(), Path::new(expected), "{comment} {request}"); + } + + #[rustfmt::skip] + let ignore = [ + ("should resolve an ignore module", "ignored", ResolveError::Ignored(f.join("ignored").into_boxed_path())) + ]; + + for (comment, request, expected) in ignore { + let resolution = resolver.resolve(f, request); + assert_eq!(resolution, Err(expected), "{comment} {request}"); + } + + Ok(()) +} + +#[test] +#[ignore = "TODO: absolute path"] +fn absolute_path() { + let f = super::fixture(); + let resolver = Resolver::new(ResolveOptions { + alias: vec![(f.join("foo").to_str().unwrap().to_string(), vec![AliasValue::Ignore])], + modules: vec![f.clone().to_str().unwrap().to_string()], + ..ResolveOptions::default() + }); + let resolution = resolver.resolve(&f, "foo/index"); + assert_eq!(resolution, Err(ResolveError::Ignored(f.into_boxed_path()))); } diff --git a/crates/oxc_resolver/tests/enhanced_resolve/test/resolve.rs b/crates/oxc_resolver/tests/enhanced_resolve/test/resolve.rs index cc3c00a19..3beefa4ed 100644 --- a/crates/oxc_resolver/tests/enhanced_resolve/test/resolve.rs +++ b/crates/oxc_resolver/tests/enhanced_resolve/test/resolve.rs @@ -44,15 +44,7 @@ fn resolve() -> Result<(), ResolveError> { for (comment, path, request, expected) in data { let resolution = resolver.resolve(&path, request)?; - let mut file_name = resolution.path().file_name().unwrap().to_string_lossy().to_string(); - if let Some(query) = resolution.query() { - file_name.push_str(query); - } - if let Some(fragment) = resolution.fragment() { - file_name.push_str(fragment); - } - let resolved_path = resolution.path().with_file_name(file_name); - assert_eq!(resolved_path, expected, "{comment} {path:?} {request}"); + assert_eq!(resolution.full_path(), expected, "{comment} {path:?} {request}"); } Ok(())