diff --git a/fixtures/package-map/.package-map.json b/fixtures/package-map/.package-map.json new file mode 100644 index 00000000..7a6e8808 --- /dev/null +++ b/fixtures/package-map/.package-map.json @@ -0,0 +1,20 @@ +{ + "packages": { + "app": { + "url": "./packages/app", + "dependencies": { + "@myorg/utils": "utils", + "@myorg/ui-lib": "ui-lib" + } + }, + "utils": { + "url": "./packages/utils" + }, + "ui-lib": { + "url": "./packages/ui-lib", + "dependencies": { + "@myorg/utils": "utils" + } + } + } +} diff --git a/fixtures/package-map/multi-version.package-map.json b/fixtures/package-map/multi-version.package-map.json new file mode 100644 index 00000000..50fa588e --- /dev/null +++ b/fixtures/package-map/multi-version.package-map.json @@ -0,0 +1,22 @@ +{ + "packages": { + "app": { + "url": "./mv/app", + "dependencies": { + "component": "component-v2" + } + }, + "legacy": { + "url": "./mv/legacy", + "dependencies": { + "component": "component-v1" + } + }, + "component-v1": { + "url": "./mv/vendor/component-1.0.0" + }, + "component-v2": { + "url": "./mv/vendor/component-2.0.0" + } + } +} diff --git a/fixtures/package-map/mv/app/index.js b/fixtures/package-map/mv/app/index.js new file mode 100644 index 00000000..18e22852 --- /dev/null +++ b/fixtures/package-map/mv/app/index.js @@ -0,0 +1 @@ +module.exports = "mv/app"; diff --git a/fixtures/package-map/mv/app/package.json b/fixtures/package-map/mv/app/package.json new file mode 100644 index 00000000..0fd84c64 --- /dev/null +++ b/fixtures/package-map/mv/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "main": "index.js" +} diff --git a/fixtures/package-map/mv/legacy/index.js b/fixtures/package-map/mv/legacy/index.js new file mode 100644 index 00000000..be213f02 --- /dev/null +++ b/fixtures/package-map/mv/legacy/index.js @@ -0,0 +1 @@ +module.exports = "mv/legacy"; diff --git a/fixtures/package-map/mv/legacy/package.json b/fixtures/package-map/mv/legacy/package.json new file mode 100644 index 00000000..3e1fae0f --- /dev/null +++ b/fixtures/package-map/mv/legacy/package.json @@ -0,0 +1,4 @@ +{ + "name": "legacy", + "main": "index.js" +} diff --git a/fixtures/package-map/mv/vendor/component-1.0.0/index.js b/fixtures/package-map/mv/vendor/component-1.0.0/index.js new file mode 100644 index 00000000..e0a0662c --- /dev/null +++ b/fixtures/package-map/mv/vendor/component-1.0.0/index.js @@ -0,0 +1 @@ +module.exports = "component@1.0.0"; diff --git a/fixtures/package-map/mv/vendor/component-1.0.0/package.json b/fixtures/package-map/mv/vendor/component-1.0.0/package.json new file mode 100644 index 00000000..544d8ea7 --- /dev/null +++ b/fixtures/package-map/mv/vendor/component-1.0.0/package.json @@ -0,0 +1,5 @@ +{ + "name": "component", + "version": "1.0.0", + "main": "index.js" +} diff --git a/fixtures/package-map/mv/vendor/component-2.0.0/index.js b/fixtures/package-map/mv/vendor/component-2.0.0/index.js new file mode 100644 index 00000000..0c27aa31 --- /dev/null +++ b/fixtures/package-map/mv/vendor/component-2.0.0/index.js @@ -0,0 +1 @@ +module.exports = "component@2.0.0"; diff --git a/fixtures/package-map/mv/vendor/component-2.0.0/package.json b/fixtures/package-map/mv/vendor/component-2.0.0/package.json new file mode 100644 index 00000000..d8185fd3 --- /dev/null +++ b/fixtures/package-map/mv/vendor/component-2.0.0/package.json @@ -0,0 +1,5 @@ +{ + "name": "component", + "version": "2.0.0", + "main": "index.js" +} diff --git a/fixtures/package-map/packages/app/index.js b/fixtures/package-map/packages/app/index.js new file mode 100644 index 00000000..b7c2414d --- /dev/null +++ b/fixtures/package-map/packages/app/index.js @@ -0,0 +1 @@ +module.exports = "app"; diff --git a/fixtures/package-map/packages/app/package.json b/fixtures/package-map/packages/app/package.json new file mode 100644 index 00000000..0fd84c64 --- /dev/null +++ b/fixtures/package-map/packages/app/package.json @@ -0,0 +1,4 @@ +{ + "name": "app", + "main": "index.js" +} diff --git a/fixtures/package-map/packages/app/src/feature.js b/fixtures/package-map/packages/app/src/feature.js new file mode 100644 index 00000000..33535fc6 --- /dev/null +++ b/fixtures/package-map/packages/app/src/feature.js @@ -0,0 +1 @@ +module.exports = "feature"; diff --git a/fixtures/package-map/packages/ui-lib/index.js b/fixtures/package-map/packages/ui-lib/index.js new file mode 100644 index 00000000..ee347d7b --- /dev/null +++ b/fixtures/package-map/packages/ui-lib/index.js @@ -0,0 +1 @@ +module.exports = "ui-lib"; diff --git a/fixtures/package-map/packages/ui-lib/package.json b/fixtures/package-map/packages/ui-lib/package.json new file mode 100644 index 00000000..f6b697dc --- /dev/null +++ b/fixtures/package-map/packages/ui-lib/package.json @@ -0,0 +1,4 @@ +{ + "name": "@myorg/ui-lib", + "main": "index.js" +} diff --git a/fixtures/package-map/packages/utils/helper.js b/fixtures/package-map/packages/utils/helper.js new file mode 100644 index 00000000..4df03785 --- /dev/null +++ b/fixtures/package-map/packages/utils/helper.js @@ -0,0 +1 @@ +module.exports = "utils/helper"; diff --git a/fixtures/package-map/packages/utils/index.js b/fixtures/package-map/packages/utils/index.js new file mode 100644 index 00000000..638e9189 --- /dev/null +++ b/fixtures/package-map/packages/utils/index.js @@ -0,0 +1 @@ +module.exports = "utils"; diff --git a/fixtures/package-map/packages/utils/package.json b/fixtures/package-map/packages/utils/package.json new file mode 100644 index 00000000..b5be492c --- /dev/null +++ b/fixtures/package-map/packages/utils/package.json @@ -0,0 +1,8 @@ +{ + "name": "@myorg/utils", + "main": "index.js", + "exports": { + ".": "./index.js", + "./helper": "./helper.js" + } +} diff --git a/napi/src/lib.rs b/napi/src/lib.rs index 0fa54cca..dad37b31 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -251,6 +251,8 @@ impl ResolverFactory { // merging options Ok(ResolveOptions { cwd: None, + // Package maps are not exposed through the Node.js binding. + package_map: None, tsconfig: op .tsconfig .map(|value| -> napi::Result<_> { diff --git a/src/error.rs b/src/error.rs index 3906c97f..45d8d51e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -108,6 +108,12 @@ pub enum ResolveError { #[error(r#"Package import specifier "{0}" is not defined in package {1}"#)] PackageImportNotDefined(String, PathBuf), + /// The importing file is not located within any package defined in the package map. + /// + /// Corresponds to Node.js `ERR_PACKAGE_MAP_EXTERNAL_FILE`. + #[error("The importing file {0} is not within any package defined in the package map")] + PackageMapExternalFile(/* importer directory */ PathBuf), + #[error("{0} is unimplemented")] Unimplemented(&'static str), diff --git a/src/lib.rs b/src/lib.rs index 2224a4c1..f0ef742e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,7 @@ mod file_url; mod node_path; mod options; mod package_json; +mod package_map; mod path; mod resolution; mod specifier; @@ -91,7 +92,7 @@ use std::{ ffi::OsStr, fmt, path::{Component, Path, PathBuf}, - sync::Arc, + sync::{Arc, OnceLock}, }; use rustc_hash::FxHashSet; @@ -99,6 +100,7 @@ use rustc_hash::FxHashSet; use crate::{ alias::{CompiledAlias, compile_alias}, context::ResolveContext as Ctx, + package_map::PackageMap, path::SLASH_START, specifier::Specifier, }; @@ -123,6 +125,9 @@ pub struct ResolverGeneric { options: ResolveOptions, cache: Arc>, alias: CompiledAlias, + /// Lazily-loaded package map (`.package-map.json`), parsed on first use. + /// `None` variant inside means [`ResolveOptions::package_map`] is unset. + package_map: OnceLock, ResolveError>>, } impl fmt::Debug for ResolverGeneric { @@ -150,13 +155,18 @@ impl ResolverGeneric { } } let cache = Arc::new(Cache::new(fs)); - Self { options, cache, alias } + Self { options, cache, alias, package_map: OnceLock::new() } } pub fn new_with_file_system(file_system: Fs, options: ResolveOptions) -> Self { let options = options.sanitize(); let alias = compile_alias(&options.alias); - Self { cache: Arc::new(Cache::new(file_system)), options, alias } + Self { + cache: Arc::new(Cache::new(file_system)), + options, + alias, + package_map: OnceLock::new(), + } } /// Clone the resolver using the same underlying cache. @@ -178,7 +188,7 @@ impl ResolverGeneric { let cache = Arc::clone(&self.cache); } } - Self { options, cache, alias } + Self { options, cache, alias, package_map: OnceLock::new() } } /// Returns the options. @@ -595,9 +605,117 @@ impl ResolverGeneric { { return Ok(path); } + // When a package map is configured, bare specifiers are resolved exclusively through it. + if self.options.package_map.is_some() { + return self.load_package_map(cached_path, specifier, tsconfig, ctx); + } self.load_package_self_or_node_modules(cached_path, specifier, tsconfig, ctx) } + /// Lazily load and parse the configured package map (`.package-map.json`). + /// + /// The result is memoized: the file is read and parsed once per resolver. + fn package_map(&self) -> Result, ResolveError> { + self.package_map + .get_or_init(|| { + // `require_bare` only calls this when `package_map` is `Some`. + let config_path = self.options.package_map.as_ref().unwrap(); + let config_path = if config_path.is_absolute() { + Cow::Borrowed(config_path.as_path()) + } else if let Some(cwd) = &self.options.cwd { + Cow::Owned(cwd.join(config_path)) + } else { + Cow::Borrowed(config_path.as_path()) + }; + let source = self.cache.fs.read_to_string(&config_path)?; + PackageMap::parse(&config_path, &source).map(Arc::new) + }) + .clone() + } + + /// Resolve a bare specifier through the configured package map. + /// + /// + fn load_package_map( + &self, + cached_path: &CachedPath, + specifier: &str, + tsconfig: Option<&TsConfig>, + ctx: &mut Ctx, + ) -> Result { + let package_map = self.package_map()?; + + // 1. Determine which package performs the resolution request from the importer's path. + let Some(importer_id) = package_map.importer_package(cached_path.path()) else { + return Err(ResolveError::PackageMapExternalFile(cached_path.to_path_buf())); + }; + + // 2. Look up the specifier's package name in the importing package's `dependencies`. + let (package_name, subpath) = Self::parse_package_specifier(specifier); + let Some(target) = package_map.resolve_dependency(importer_id, package_name) else { + // Not declared as a dependency (or points to an unknown package id): MODULE_NOT_FOUND. + return Err(ResolveError::NotFound(specifier.to_string())); + }; + + // 3. Forward to the regular resolution algorithm, treating the target `url` as the package + // directory (exports field, main, index, ...). + let package_dir = self.cache.value(target.path()); + self.resolve_in_package_dir(&package_dir, specifier, subpath, tsconfig, ctx) + } + + /// Finish resolution inside a package directory located via the package map. + /// + /// Mirrors the per-package-directory branch of [`Self::load_node_modules`]. + fn resolve_in_package_dir( + &self, + package_dir: &CachedPath, + specifier: &str, + subpath: &str, + tsconfig: Option<&TsConfig>, + ctx: &mut Ctx, + ) -> Result { + if subpath.is_empty() { + ctx.with_fully_specified(false); + } + + // LOAD_PACKAGE_EXPORTS(specifier, package_dir) + if self.is_dir(package_dir, ctx) + && let Some(path) = + self.load_package_exports(specifier, subpath, package_dir, tsconfig, ctx)? + { + return Ok(path); + } + + // LOAD_AS_FILE(package_dir/subpath) / LOAD_AS_DIRECTORY(package_dir/subpath) + let target = package_dir.normalize_with(Self::dot_subpath(subpath).as_ref(), &self.cache); + + if self.options.resolve_to_context { + return self + .is_dir(&target, ctx) + .then(|| target.clone()) + .ok_or_else(|| ResolveError::NotFound(specifier.to_string())); + } + + if !subpath.is_empty() + && !specifier.ends_with('/') + && let Some(path) = self.load_as_file(&target, tsconfig, ctx)? + { + return Ok(path); + } + if self.is_dir(&target, ctx) { + if let Some(path) = self.load_browser_field_or_alias(&target, tsconfig, ctx)? { + return Ok(path); + } + if let Some(path) = self.load_as_directory(&target, tsconfig, ctx)? { + return Ok(path); + } + } else if let Some(path) = self.load_as_file(&target, tsconfig, ctx)? { + return Ok(path); + } + + Err(ResolveError::NotFound(specifier.to_string())) + } + /// enhanced-resolve: ParsePlugin. /// /// It's allowed to escape # as \0# to avoid parsing it as fragment. diff --git a/src/options.rs b/src/options.rs index c08a69ef..75a73160 100644 --- a/src/options.rs +++ b/src/options.rs @@ -22,6 +22,18 @@ pub struct ResolveOptions { /// Default `None` pub tsconfig: Option, + /// Enable Node.js [package maps](https://nodejs.org/docs/latest/api/packages.html#package-maps) + /// resolution using the given `.package-map.json` file. + /// + /// When set, bare specifiers (that are not Node.js builtin modules) are resolved exclusively + /// through the package map instead of walking `node_modules`. Relative, absolute and builtin + /// specifiers are unaffected. + /// + /// The path may be absolute or relative to [`ResolveOptions::cwd`]. + /// + /// Default `None` + pub package_map: Option, + /// Create aliases to import or require certain modules more easily. /// /// An alias is used to replace a whole path or part of a path. @@ -220,6 +232,23 @@ impl ResolveOptions { /// let options = ResolveOptions::default().with_condition_names(&["bar"]); /// assert_eq!(options.condition_names, vec!["bar".to_string()]) /// ``` + /// Sets the value for [ResolveOptions::package_map] + /// + /// ## Examples + /// + /// ``` + /// use oxc_resolver::ResolveOptions; + /// use std::path::PathBuf; + /// + /// let options = ResolveOptions::default().with_package_map("/path/to/.package-map.json"); + /// assert_eq!(options.package_map, Some(PathBuf::from("/path/to/.package-map.json"))); + /// ``` + #[must_use] + pub fn with_package_map>(mut self, path: P) -> Self { + self.package_map = Some(path.into()); + self + } + #[must_use] pub fn with_condition_names(mut self, names: &[&str]) -> Self { self.condition_names = names.iter().map(ToString::to_string).collect::>(); @@ -541,6 +570,7 @@ impl Default for ResolveOptions { Self { cwd: None, tsconfig: None, + package_map: None, alias: vec![], alias_fields: vec![], condition_names: vec![], @@ -576,6 +606,9 @@ impl fmt::Display for ResolveOptions { if let Some(tsconfig) = &self.tsconfig { write!(f, "tsconfig:{tsconfig:?},")?; } + if let Some(package_map) = &self.package_map { + write!(f, "package_map:{},", package_map.display())?; + } if !self.alias.is_empty() { write!(f, "alias:{:?},", self.alias)?; } @@ -729,6 +762,7 @@ mod test { roots: vec![], symlinks: false, tsconfig: None, + package_map: None, module_type: false, allow_package_exports_in_directory_resolve: false, }; diff --git a/src/package_map.rs b/src/package_map.rs new file mode 100644 index 00000000..a9d8be3f --- /dev/null +++ b/src/package_map.rs @@ -0,0 +1,169 @@ +//! Node.js [package maps](https://nodejs.org/docs/latest/api/packages.html#package-maps). +//! +//! A package map is a single static JSON file (`.package-map.json`) that controls package +//! resolution without relying on the `node_modules` folder structure. It declares a set of +//! packages, each identified by a unique *package id*, with a filesystem location (`url`) and an +//! explicit map of bare-specifier *dependencies* to other package ids. +//! +//! ```json +//! { +//! "packages": { +//! "app": { +//! "url": "./packages/app", +//! "dependencies": { "@myorg/utils": "utils" } +//! }, +//! "utils": { "url": "./packages/utils" } +//! } +//! } +//! ``` + +use std::path::{Path, PathBuf}; + +use rustc_hash::FxHashMap; +use serde::Deserialize; + +use crate::{ResolveError, path::PathUtil}; + +/// A parsed `.package-map.json` file. +#[derive(Debug)] +pub struct PackageMap { + /// Package id -> resolved package entry. + packages: FxHashMap, +} + +/// A single entry in the [`PackageMap`]'s `packages` object. +#[derive(Debug)] +pub struct PackageMapEntry { + /// Absolute filesystem path decoded from the entry's `url` field. + path: PathBuf, + /// Maps a bare-specifier package name to a package id within the same package map. + dependencies: FxHashMap, +} + +impl PackageMapEntry { + /// The resolved package directory. + pub(crate) fn path(&self) -> &Path { + &self.path + } +} + +/// Raw shape of `.package-map.json` for deserialization. +#[derive(Deserialize)] +struct RawPackageMap { + #[serde(default)] + packages: FxHashMap, +} + +#[derive(Deserialize)] +struct RawPackageMapEntry { + url: String, + #[serde(default)] + dependencies: FxHashMap, +} + +impl PackageMap { + /// Parse a `.package-map.json` from its `source`, resolving relative `url`s against the + /// directory containing `config_path`. + /// + /// # Errors + /// + /// * [`ResolveError::Json`] when the file is not valid JSON. + /// * [`ResolveError::PathNotSupported`] when an entry's `url` cannot be turned into a path. + pub(crate) fn parse(config_path: &Path, source: &str) -> Result { + let raw: RawPackageMap = serde_json::from_str(source).map_err(|error| { + ResolveError::from_serde_json_error(config_path.to_path_buf(), &error) + })?; + // Relative `url`s are resolved against the configuration file URL, i.e. its directory. + let base_dir = config_path.parent().unwrap_or(config_path); + let mut packages = FxHashMap::default(); + packages.reserve(raw.packages.len()); + for (id, entry) in raw.packages { + let path = resolve_url(base_dir, &entry.url)?; + packages.insert(id, PackageMapEntry { path, dependencies: entry.dependencies }); + } + Ok(Self { packages }) + } + + /// Find the package whose location contains `dir` (the importing file's directory), returning + /// its package id. The most specific (deepest) package location wins when several match. + pub(crate) fn importer_package(&self, dir: &Path) -> Option<&str> { + self.packages + .iter() + .filter(|(_, entry)| dir.starts_with(&entry.path)) + .max_by_key(|(_, entry)| entry.path.as_os_str().len()) + .map(|(id, _)| id.as_str()) + } + + /// Resolve a bare-specifier `package_name` declared as a dependency of the package identified + /// by `importer_id`, returning the target package entry. + pub(crate) fn resolve_dependency( + &self, + importer_id: &str, + package_name: &str, + ) -> Option<&PackageMapEntry> { + let target_id = self.packages.get(importer_id)?.dependencies.get(package_name)?; + self.packages.get(target_id) + } +} + +/// Resolve an entry `url` to an absolute path. +/// +/// Only the `file:` protocol is supported. Non-`file:` URLs are treated as relative references +/// resolved against the configuration file's directory, matching `new URL(url, configFileURL)`. +fn resolve_url(base_dir: &Path, url: &str) -> Result { + if url.starts_with("file:") { + cfg_if::cfg_if! { + if #[cfg(not(target_arch = "wasm32"))] { + let path = crate::file_url::resolve_file_protocol(url)?; + Ok(Path::new(path.as_ref()).normalize()) + } else { + Err(ResolveError::PathNotSupported(PathBuf::from(url))) + } + } + } else { + Ok(base_dir.join(url).normalize()) + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::PackageMap; + + #[test] + fn parses_and_resolves_relative_urls() { + let config = Path::new("/project/.package-map.json"); + let source = r#"{ + "packages": { + "app": { "url": "./packages/app", "dependencies": { "utils": "utils" } }, + "utils": { "url": "./packages/utils" } + } + }"#; + let map = PackageMap::parse(config, source).unwrap(); + + // Importer lookup picks the deepest matching package, resolving the relative `url`. + assert_eq!(map.importer_package(Path::new("/project/packages/app/src")), Some("app")); + assert_eq!(map.importer_package(Path::new("/project/packages/utils")), Some("utils")); + assert_eq!(map.importer_package(Path::new("/project/outside")), None); + + // Dependency resolution follows the `dependencies` table. + assert_eq!( + map.resolve_dependency("app", "utils").unwrap().path(), + Path::new("/project/packages/utils") + ); + // `utils` declares no dependencies, and `app` is not one of them. + assert!(map.resolve_dependency("utils", "app").is_none()); + } + + #[test] + fn missing_packages_field_is_empty() { + let map = PackageMap::parse(Path::new("/p/.package-map.json"), "{}").unwrap(); + assert!(map.importer_package(Path::new("/p")).is_none()); + } + + #[test] + fn invalid_json_is_an_error() { + assert!(PackageMap::parse(Path::new("/p/.package-map.json"), "not json").is_err()); + } +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 50e95885..7c6076a6 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -17,6 +17,7 @@ mod missing; mod module_type; mod modules; mod package_json; +mod package_map; #[cfg(feature = "yarn_pnp")] mod pnp; mod resolution; diff --git a/src/tests/package_map.rs b/src/tests/package_map.rs new file mode 100644 index 00000000..9f58557a --- /dev/null +++ b/src/tests/package_map.rs @@ -0,0 +1,120 @@ +//! Tests for Node.js package maps. +//! +//! + +use std::path::PathBuf; + +use crate::{ResolveError, ResolveOptions, Resolver}; + +fn dir() -> PathBuf { + super::fixture_root().join("package-map") +} + +fn resolver() -> Resolver { + Resolver::new(ResolveOptions { + package_map: Some(dir().join(".package-map.json")), + ..ResolveOptions::default() + }) +} + +#[test] +fn maps_bare_specifier_to_dependency() { + let resolver = resolver(); + let app = dir().join("packages/app"); + + // `@myorg/utils` is declared as a dependency of `app`, keyed to package `utils`. + let resolution = resolver.resolve(&app, "@myorg/utils").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/utils/index.js"))); + + // `@myorg/ui-lib` -> package `ui-lib`. + let resolution = resolver.resolve(&app, "@myorg/ui-lib").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/ui-lib/index.js"))); +} + +#[test] +fn resolves_subpath_through_exports() { + let resolver = resolver(); + let app = dir().join("packages/app"); + + // `@myorg/utils/helper` -> package `utils`, resolved via its `exports` field. + let resolution = resolver.resolve(&app, "@myorg/utils/helper").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/utils/helper.js"))); +} + +#[test] +fn maps_from_a_nested_importer_directory() { + let resolver = resolver(); + // Importer lives in a subdirectory of the `app` package. + let nested = dir().join("packages/app/src"); + + let resolution = resolver.resolve(&nested, "@myorg/utils").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/utils/index.js"))); +} + +#[test] +fn dependency_isolation() { + let resolver = resolver(); + // `utils` declares no dependencies, so it cannot see `@myorg/ui-lib`. + let utils = dir().join("packages/utils"); + let resolution = resolver.resolve(&utils, "@myorg/ui-lib"); + assert_eq!(resolution, Err(ResolveError::NotFound("@myorg/ui-lib".into()))); + + // `app` does not declare `@myorg/missing`. + let app = dir().join("packages/app"); + let resolution = resolver.resolve(&app, "@myorg/missing"); + assert_eq!(resolution, Err(ResolveError::NotFound("@myorg/missing".into()))); +} + +#[test] +fn external_importer_file_errors() { + let resolver = resolver(); + // The package-map root is not within any mapped package location. + let resolution = resolver.resolve(dir(), "@myorg/utils"); + assert_eq!(resolution, Err(ResolveError::PackageMapExternalFile(dir()))); +} + +#[test] +fn relative_specifiers_bypass_the_package_map() { + let resolver = resolver(); + let app = dir().join("packages/app"); + // Relative specifiers are resolved normally, even from an importer outside any package. + let resolution = resolver.resolve(&app, "./src/feature.js").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/app/src/feature.js"))); + + // Even from an external directory, relative resolution is unaffected. + let resolution = resolver.resolve(dir(), "./packages/app/index.js").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("packages/app/index.js"))); +} + +#[test] +fn builtin_modules_are_exempt() { + let resolver = Resolver::new(ResolveOptions { + package_map: Some(dir().join(".package-map.json")), + builtin_modules: true, + ..ResolveOptions::default() + }); + let app = dir().join("packages/app"); + let resolution = resolver.resolve(&app, "fs"); + assert!( + matches!(resolution, Err(ResolveError::Builtin { .. })), + "expected builtin, got {resolution:?}" + ); +} + +#[test] +fn multiple_versions_are_isolated() { + let resolver = Resolver::new(ResolveOptions { + package_map: Some(dir().join("multi-version.package-map.json")), + ..ResolveOptions::default() + }); + + // `app` depends on component v2. + let app = dir().join("mv/app"); + let resolution = resolver.resolve(&app, "component").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("mv/vendor/component-2.0.0/index.js"))); + + // `legacy` depends on component v1. + let legacy = dir().join("mv/legacy"); + let resolution = resolver.resolve(&legacy, "component").map(|r| r.full_path()); + assert_eq!(resolution, Ok(dir().join("mv/vendor/component-1.0.0/index.js"))); +}