/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
libs/resolver/workspace.rs
3 920 строк
119 KB
Bartek Iwańczuk
fix(npm): don't retain full npm `exports` value + fix `--watch` memory leak (#35677)
02 июл 2026, 01:21
Не верифицирован
02 июл 2026, 01:21
29496d5
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. // use super::UrlRc; use std::borrow::Cow; use std::collections::BTreeMap; use std::collections::HashMap; use std::fmt; use std::path::Path; use std::path::PathBuf; use deno_config::deno_json::ConfigFileError; use deno_config::deno_json::ConfigFileRc; use deno_config::workspace::ResolverWorkspaceJsrPackage; use deno_config::workspace::Workspace; use deno_error::JsError; use deno_maybe_sync::MaybeDashMap; use deno_maybe_sync::new_rc; use deno_media_type::MediaType; use deno_npm::registry::NpmPackageVersionInfo; use deno_package_json::PackageJsonDepValue; use deno_package_json::PackageJsonDepValueParseError; use deno_package_json::PackageJsonDepWorkspaceReq; use deno_package_json::PackageJsonDepsRc; use deno_package_json::PackageJsonRc; use deno_path_util::SpecifierError; use deno_path_util::url_from_directory_path; use deno_path_util::url_from_file_path; use deno_path_util::url_to_file_path; use deno_semver::RangeSetOrTag; use deno_semver::SmallStackString; use deno_semver::StackString; use deno_semver::Version; use deno_semver::VersionReq; use deno_semver::jsr::JsrPackageReqReference; use deno_semver::package::PackageName; use deno_semver::package::PackageReq; use deno_terminal::colors; use import_map::ImportMap; use import_map::ImportMapDiagnostic; use import_map::ImportMapError; use import_map::ImportMapErrorKind; use import_map::ImportMapWithDiagnostics; use indexmap::IndexMap; use node_resolver::NodeResolutionKind; use parking_lot::RwLock; use serde::Deserialize; use serde::Serialize; use sys_traits::FsMetadata; use sys_traits::FsMetadataValue; use sys_traits::FsRead; use thiserror::Error; use url::Url; use crate::collections::FolderScopedMap; use crate::deno_json::CompilerOptionsModuleResolution; use crate::deno_json::CompilerOptionsPaths; use crate::deno_json::CompilerOptionsResolverRc; #[allow(clippy::disallowed_types, reason = "definition")] type UrlRc = deno_maybe_sync::MaybeArc<Url>; #[derive(Debug)] struct PkgJsonResolverFolderConfig { deps: PackageJsonDepsRc, pkg_json: PackageJsonRc, } #[derive(Debug, Error, JsError)] pub enum WorkspaceResolverCreateError { #[class(inherit)] #[error("Failed loading import map specified in '{referrer}'")] ImportMapFetch { referrer: Url, #[source] #[inherit] source: Box<ConfigFileError>, }, #[class(inherit)] #[error(transparent)] ImportMap( #[from] #[inherit] ImportMapError, ), #[class(type)] #[error("Package '{name}' not found in catalog")] CatalogPackageNotFound { name: String }, } /// Whether to resolve dependencies by reading the dependencies list /// from a package.json #[derive( Debug, Default, Serialize, Deserialize, Copy, Clone, PartialEq, Eq, )] pub enum PackageJsonDepResolution { /// Resolves based on the dep entries in the package.json. #[default] Enabled, /// Doesn't use the package.json to resolve dependencies. Let's the caller /// resolve based on the file system. Disabled, } #[derive( Debug, Default, Serialize, Deserialize, Copy, Clone, PartialEq, Eq, )] pub enum SloppyImportsOptions { Enabled, #[default] Unspecified, } /// Toggle FS metadata caching when probing files for sloppy imports and /// `compilerOptions.rootDirs` resolution. #[derive( Debug, Default, Serialize, Deserialize, Copy, Clone, PartialEq, Eq, )] pub enum FsCacheOptions { #[default] Enabled, Disabled, } #[derive(Debug, Default, Clone)] pub struct CreateResolverOptions { pub pkg_json_dep_resolution: PackageJsonDepResolution, pub specified_import_map: Option<SpecifiedImportMap>, pub sloppy_imports_options: SloppyImportsOptions, pub fs_cache_options: FsCacheOptions, } #[derive(Debug, Clone)] pub struct SpecifiedImportMap { pub base_url: Url, pub value: serde_json::Value, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MappedResolutionDiagnostic { ConstraintNotMatchedLocalVersion { /// If it was for a link (true) or workspace (false) member. is_link: bool, reference: JsrPackageReqReference, local_version: Version, }, } impl std::fmt::Display for MappedResolutionDiagnostic { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ConstraintNotMatchedLocalVersion { is_link, reference, local_version, } => { write!( f, "{0} '{1}@{2}' was not used because it did not match '{1}@{3}'", if *is_link { "Linked package" } else { "Workspace member" }, reference.req().name, local_version, reference.req().version_req ) } } } } #[derive(Debug, Clone)] pub enum MappedResolution<'a> { Normal { specifier: Url, sloppy_reason: Option<SloppyImportsResolutionReason>, used_import_map: bool, used_compiler_options_root_dirs: bool, maybe_diagnostic: Option<Box<MappedResolutionDiagnostic>>, }, WorkspaceJsrPackage { specifier: Url, pkg_req_ref: JsrPackageReqReference, }, /// Resolved a bare specifier to a package.json that was a workspace member. WorkspaceNpmPackage { target_pkg_json: &'a PackageJsonRc, pkg_name: &'a str, sub_path: Option<String>, }, PackageJson { pkg_json: &'a PackageJsonRc, alias: &'a str, sub_path: Option<String>, dep_result: &'a Result<PackageJsonDepValue, PackageJsonDepValueParseError>, }, PackageJsonImport { pkg_json: &'a PackageJsonRc, }, } #[derive(Debug, Clone, Error, JsError)] #[class(type)] pub enum WorkspaceResolveError { #[error("Failed joining '{}' to '{}'. {:#}", .sub_path, .base, .error)] InvalidExportPath { base: Url, sub_path: String, error: url::ParseError, }, #[error("Unknown export '{}' for '{}'.\n Package exports:\n{}", export_name, package_name, .exports.iter().map(|e| format!(" * {}", e)).collect::<Vec<_>>().join("\n"))] UnknownExport { package_name: String, export_name: String, exports: Vec<String>, }, } #[derive(Debug, Error, JsError)] #[class(type)] #[error( "Import \"{}\" via 'compilerOptions.paths[\"{}\"]' did not match an existing file", prior_resolution.as_ref().map(|s| s.as_str()).unwrap_or(specifier.as_str()), matched_key )] pub struct NotFoundInCompilerOptionsPathsError { specifier: String, referrer: Url, matched_key: String, prior_resolution: Option<Url>, } #[derive(Debug, Error, JsError)] pub enum MappedResolutionError { #[class(inherit)] #[error(transparent)] Specifier(#[from] SpecifierError), #[class(inherit)] #[error(transparent)] ImportMap(#[from] ImportMapError), #[class(inherit)] #[error(transparent)] Workspace(#[from] WorkspaceResolveError), #[class(inherit)] #[error(transparent)] NotFoundInCompilerOptionsPaths( #[from] Box<NotFoundInCompilerOptionsPathsError>, ), } impl MappedResolutionError { pub fn is_unmapped_bare_specifier(&self) -> bool { match self { MappedResolutionError::Specifier(err) => match err { SpecifierError::InvalidUrl(_) => false, SpecifierError::ImportPrefixMissing { .. } => true, }, MappedResolutionError::ImportMap(err) => { matches!(**err, ImportMapErrorKind::UnmappedBareSpecifier(_, _)) } MappedResolutionError::Workspace(_) => false, MappedResolutionError::NotFoundInCompilerOptionsPaths(_) => false, } } } #[derive(Error, Debug, JsError)] #[class(inherit)] #[error(transparent)] pub struct WorkspaceResolvePkgJsonFolderError( Box<WorkspaceResolvePkgJsonFolderErrorKind>, ); impl WorkspaceResolvePkgJsonFolderError { pub fn as_kind(&self) -> &WorkspaceResolvePkgJsonFolderErrorKind { &self.0 } pub fn into_kind(self) -> WorkspaceResolvePkgJsonFolderErrorKind { *self.0 } } impl<E> From<E> for WorkspaceResolvePkgJsonFolderError where WorkspaceResolvePkgJsonFolderErrorKind: From<E>, { fn from(err: E) -> Self { WorkspaceResolvePkgJsonFolderError(Box::new( WorkspaceResolvePkgJsonFolderErrorKind::from(err), )) } } #[derive(Debug, Error, JsError, Clone, PartialEq, Eq)] #[class(type)] pub enum WorkspaceResolvePkgJsonFolderErrorKind { #[error("Could not find package.json with name '{0}' in workspace.")] NotFound(String), #[error( "Found package.json in workspace, but version '{1}' didn't satisfy constraint '{0}'." )] VersionNotSatisfied(VersionReq, Version), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CachedMetadataFsEntry { File, Dir, } #[derive(Debug)] struct CachedMetadataFs<TSys: FsMetadata> { sys: TSys, cache: Option<MaybeDashMap<PathBuf, Option<CachedMetadataFsEntry>>>, } impl<TSys: FsMetadata> CachedMetadataFs<TSys> { fn new(sys: TSys, options: FsCacheOptions) -> Self { Self { sys, cache: match options { FsCacheOptions::Enabled => Some(Default::default()), FsCacheOptions::Disabled => None, }, } } fn stat_sync(&self, path: &Path) -> Option<CachedMetadataFsEntry> { if let Some(cache) = &self.cache && let Some(entry) = cache.get(path) { return *entry; } let entry = self.sys.fs_metadata(path).ok().and_then(|stat| { if stat.file_type().is_file() { Some(CachedMetadataFsEntry::File) } else if stat.file_type().is_dir() { Some(CachedMetadataFsEntry::Dir) } else { None } }); if let Some(cache) = &self.cache { cache.insert(path.to_owned(), entry); } entry } fn is_file(&self, path: &Path) -> bool { self.stat_sync(path) == Some(CachedMetadataFsEntry::File) } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SloppyImportsResolutionReason { /// Ex. `./file.js` to `./file.ts` JsToTs, /// Ex. `./file` to `./file.ts` NoExtension, /// Ex. `./dir` to `./dir/index.ts` Directory, } impl SloppyImportsResolutionReason { pub fn suggestion_message_for_specifier(&self, specifier: &Url) -> String { format!("Maybe {}", self.base_message_for_specifier(specifier)) } pub fn quick_fix_message_for_specifier(&self, specifier: &Url) -> String { let message = self.base_message_for_specifier(specifier); let mut chars = message.chars(); format!( "{}{}.", chars.next().unwrap().to_uppercase(), chars.as_str() ) } fn base_message_for_specifier(&self, specifier: &Url) -> String { match self { Self::JsToTs => { let media_type = MediaType::from_specifier(specifier); format!("change the extension to '{}'", media_type.as_ts_extension()) } Self::NoExtension => { let media_type = MediaType::from_specifier(specifier); format!("add a '{}' extension", media_type.as_ts_extension()) } Self::Directory => { let file_name = specifier .path() .rsplit_once('/') .map(|(_, file_name)| file_name) .unwrap_or(specifier.path()); format!("specify path to '{}' file in directory instead", file_name) } } } } #[derive(Debug)] struct SloppyImportsResolver<TSys: FsMetadata> { compiler_options_resolver: CompilerOptionsResolverCellRc, fs: CachedMetadataFs<TSys>, enabled_by_options: bool, } impl<TSys: FsMetadata> SloppyImportsResolver<TSys> { fn new( fs: CachedMetadataFs<TSys>, compiler_options_resolver: CompilerOptionsResolverCellRc, options: SloppyImportsOptions, ) -> Self { Self { fs, compiler_options_resolver, enabled_by_options: match options { SloppyImportsOptions::Enabled => true, SloppyImportsOptions::Unspecified => false, }, } } fn resolve( &self, specifier: &Url, referrer: &Url, resolution_kind: ResolutionKind, ) -> Option<(Url, SloppyImportsResolutionReason)> { if !self.enabled_by_options && self .compiler_options_resolver .read() .for_specifier(referrer) .module_resolution() != CompilerOptionsModuleResolution::Bundler { return None; } fn path_without_ext( path: &Path, media_type: MediaType, ) -> Option<Cow<'_, str>> { let old_path_str = path.to_string_lossy(); match media_type { MediaType::Unknown => Some(old_path_str), _ => old_path_str .strip_suffix(media_type.as_ts_extension()) .map(|s| Cow::Owned(s.to_string())), } } fn media_types_to_paths( path_no_ext: &str, original_media_type: MediaType, probe_media_type_types: Vec<MediaType>, reason: SloppyImportsResolutionReason, ) -> Vec<(PathBuf, SloppyImportsResolutionReason)> { probe_media_type_types .into_iter() .filter(|media_type| *media_type != original_media_type) .map(|media_type| { ( PathBuf::from(format!( "{}{}", path_no_ext, media_type.as_ts_extension() )), reason, ) }) .collect::<Vec<_>>() } if specifier.scheme() != "file" { return None; } let path = url_to_file_path(specifier).ok()?; let probe_paths: Vec<(PathBuf, SloppyImportsResolutionReason)> = match self.fs.stat_sync(&path) { Some(CachedMetadataFsEntry::File) => { if resolution_kind.is_types() { let media_type = MediaType::from_specifier(specifier); // attempt to resolve the .d.ts file before the .js file let probe_media_type_types = match media_type { MediaType::JavaScript => { vec![(MediaType::Dts), MediaType::JavaScript] } MediaType::Mjs => { vec![MediaType::Dmts, MediaType::Dts, MediaType::Mjs] } MediaType::Cjs => { vec![MediaType::Dcts, MediaType::Dts, MediaType::Cjs] } _ => return None, }; let path_no_ext = path_without_ext(&path, media_type)?; media_types_to_paths( &path_no_ext, media_type, probe_media_type_types, SloppyImportsResolutionReason::JsToTs, ) } else { return None; } } entry @ None | entry @ Some(CachedMetadataFsEntry::Dir) => { let media_type = MediaType::from_specifier(specifier); let probe_media_type_types = match media_type { MediaType::JavaScript => ( if resolution_kind.is_types() { vec![MediaType::TypeScript, MediaType::Tsx, MediaType::Dts] } else { vec![MediaType::TypeScript, MediaType::Tsx] }, SloppyImportsResolutionReason::JsToTs, ), MediaType::Jsx => { (vec![MediaType::Tsx], SloppyImportsResolutionReason::JsToTs) } MediaType::Mjs => ( if resolution_kind.is_types() { vec![MediaType::Mts, MediaType::Dmts, MediaType::Dts] } else { vec![MediaType::Mts] }, SloppyImportsResolutionReason::JsToTs, ), MediaType::Cjs => ( if resolution_kind.is_types() { vec![MediaType::Cts, MediaType::Dcts, MediaType::Dts] } else { vec![MediaType::Cts] }, SloppyImportsResolutionReason::JsToTs, ), MediaType::TypeScript | MediaType::Mts | MediaType::Cts | MediaType::Dts | MediaType::Dmts | MediaType::Dcts | MediaType::Tsx | MediaType::Json | MediaType::Jsonc | MediaType::Json5 | MediaType::Markdown | MediaType::Wasm | MediaType::Css | MediaType::Html | MediaType::Sql | MediaType::SourceMap => { return None; } MediaType::Unknown => ( if resolution_kind.is_types() { vec![ MediaType::TypeScript, MediaType::Tsx, MediaType::Mts, MediaType::Dts, MediaType::Dmts, MediaType::Dcts, MediaType::JavaScript, MediaType::Jsx, MediaType::Mjs, ] } else { vec![ MediaType::TypeScript, MediaType::JavaScript, MediaType::Tsx, MediaType::Jsx, MediaType::Mts, MediaType::Mjs, ] }, SloppyImportsResolutionReason::NoExtension, ), }; let mut probe_paths = match path_without_ext(&path, media_type) { Some(path_no_ext) => media_types_to_paths( &path_no_ext, media_type, probe_media_type_types.0, probe_media_type_types.1, ), None => vec![], }; if matches!(entry, Some(CachedMetadataFsEntry::Dir)) { // try to resolve at the index file if resolution_kind.is_types() { probe_paths.push(( path.join("index.ts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.mts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.d.ts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.d.mts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.js"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.mjs"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.tsx"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.jsx"), SloppyImportsResolutionReason::Directory, )); } else { probe_paths.push(( path.join("index.ts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.mts"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.tsx"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.js"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.mjs"), SloppyImportsResolutionReason::Directory, )); probe_paths.push(( path.join("index.jsx"), SloppyImportsResolutionReason::Directory, )); } } if probe_paths.is_empty() { return None; } probe_paths } }; for (probe_path, reason) in probe_paths { if self.fs.is_file(&probe_path) && let Ok(specifier) = url_from_file_path(&probe_path) { return Some((specifier, reason)); } } None } } pub fn sloppy_imports_resolve<TSys: FsMetadata>( specifier: &Url, resolution_kind: ResolutionKind, sys: TSys, ) -> Option<(Url, SloppyImportsResolutionReason)> { SloppyImportsResolver::new( CachedMetadataFs::new(sys, FsCacheOptions::Enabled), Default::default(), SloppyImportsOptions::Enabled, ) // The referrer is used to determine the applicable compiler options, which // can force-override `SloppyImportOptions::Disabled` depending on // `moduleResolution`. But `SloppyImportOptions::Enabled` is set. .resolve(specifier, &Url::parse("unknown:").unwrap(), resolution_kind) } #[allow(clippy::disallowed_types, reason = "definition")] type SloppyImportsResolverRc<T> = deno_maybe_sync::MaybeArc<SloppyImportsResolver<T>>; #[derive(Debug, Clone, PartialEq, Eq)] pub enum CompilerOptionsRootDirsDiagnostic { InvalidType(Url), InvalidEntryType(Url, usize), UnexpectedError(Url, String), UnexpectedEntryError(Url, usize, String), } impl fmt::Display for CompilerOptionsRootDirsDiagnostic { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::InvalidType(s) => write!( f, "Invalid value for \"compilerOptions.rootDirs\" (\"{s}\"). Expected a string." ), Self::InvalidEntryType(s, i) => write!( f, "Invalid value for \"compilerOptions.rootDirs[{i}]\" (\"{s}\"). Expected a string." ), Self::UnexpectedError(s, message) => write!( f, "Unexpected error while parsing \"compilerOptions.rootDirs\" (\"{s}\"): {message}" ), Self::UnexpectedEntryError(s, i, message) => write!( f, "Unexpected error while parsing \"compilerOptions.rootDirs[{i}]\" (\"{s}\"): {message}" ), } } } fn resolve_types_with_compiler_options_paths( specifier: &str, referrer: &Url, paths: &CompilerOptionsPaths, sloppy_imports_resolver: &SloppyImportsResolver<impl FsMetadata>, ) -> Option<Result<(Url, Option<SloppyImportsResolutionReason>), String>> { if referrer.scheme() != "file" { return None; } let (candidates, matched_key) = paths.resolve_candidates(specifier)?; for candidate_specifier in candidates { let Ok(candidate_path) = url_to_file_path(&candidate_specifier) else { continue; }; if sloppy_imports_resolver.fs.is_file(&candidate_path) { return Some(Ok((candidate_specifier, None))); } else if let Some((candidate_specifier, sloppy_reason)) = sloppy_imports_resolver.resolve( &candidate_specifier, referrer, ResolutionKind::Types, ) { return Some(Ok((candidate_specifier, Some(sloppy_reason)))); } } Some(Err(matched_key)) } fn resolve_types_with_compiler_options_root_dirs( specifier: &Url, referrer: &Url, root_dirs: &[Url], sloppy_imports_resolver: &SloppyImportsResolver<impl FsMetadata>, ) -> Option<(Url, Option<SloppyImportsResolutionReason>)> { if specifier.scheme() != "file" || referrer.scheme() != "file" { return None; } let (matched_root_dir, suffix) = root_dirs .iter() .filter_map(|r| { let suffix = specifier.as_str().strip_prefix(r.as_str())?; Some((r, suffix)) }) .max_by_key(|(r, _)| r.as_str().len())?; for root_dir in root_dirs { if root_dir == matched_root_dir { continue; } let Ok(candidate_specifier) = root_dir.join(suffix) else { continue; }; let Ok(candidate_path) = url_to_file_path(&candidate_specifier) else { continue; }; if sloppy_imports_resolver.fs.is_file(&candidate_path) { return Some((candidate_specifier, None)); } else if let Some((candidate_specifier, sloppy_reason)) = sloppy_imports_resolver.resolve( &candidate_specifier, referrer, ResolutionKind::Types, ) { return Some((candidate_specifier, Some(sloppy_reason))); } } None } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ResolutionKind { /// Resolving for code that will be executed. Execution, /// Resolving for code that will be used for type information. Types, } impl ResolutionKind { pub fn is_types(&self) -> bool { *self == ResolutionKind::Types } } impl From<NodeResolutionKind> for ResolutionKind { fn from(value: NodeResolutionKind) -> Self { match value { NodeResolutionKind::Execution => Self::Execution, NodeResolutionKind::Types => Self::Types, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum WorkspaceResolverDiagnostic<'a> { ImportMap(&'a ImportMapDiagnostic), CompilerOptionsRootDirs(&'a CompilerOptionsRootDirsDiagnostic), } impl fmt::Display for WorkspaceResolverDiagnostic<'_> { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { match self { Self::ImportMap(d) => write!(f, "Import map: {d}"), Self::CompilerOptionsRootDirs(d) => d.fmt(f), } } } #[allow(clippy::disallowed_types, reason = "definition")] type CompilerOptionsResolverCellRc = deno_maybe_sync::MaybeArc<RwLock<CompilerOptionsResolverRc>>; #[derive(Debug)] pub struct WorkspaceResolver<TSys: FsMetadata + FsRead> { workspace_root: UrlRc, jsr_pkgs: Vec<ResolverWorkspaceJsrPackage>, maybe_import_map: Option<ImportMapWithDiagnostics>, pkg_jsons: FolderScopedMap<PkgJsonResolverFolderConfig>, pkg_json_dep_resolution: PackageJsonDepResolution, sloppy_imports_options: SloppyImportsOptions, fs_cache_options: FsCacheOptions, compiler_options_resolver: CompilerOptionsResolverCellRc, sloppy_imports_resolver: SloppyImportsResolverRc<TSys>, catalogs: IndexMap<String, IndexMap<String, String>>, } impl<TSys: FsMetadata + FsRead> WorkspaceResolver<TSys> { pub fn from_workspace( workspace: &Workspace, sys: TSys, options: CreateResolverOptions, ) -> Result<Self, WorkspaceResolverCreateError> { fn resolve_import_map( sys: &impl FsRead, workspace: &Workspace, specified_import_map: Option<SpecifiedImportMap>, ) -> Result<Option<ImportMapWithDiagnostics>, WorkspaceResolverCreateError> { // Replaces `catalog:`/`catalog:<name>` string values in an import map // value's `imports` and `scopes` with the `npm:<name>@<version_req>` // specifier from the workspace root's catalog, so everything downstream // (resolution, installation, the lockfile) sees a regular npm specifier. // A trailing slash entry is added for "directory" imports, mirroring the // expansion `to_import_map_value` applies to inline `npm:` specifiers. fn expand_catalog_specifiers( value: serde_json::Value, catalogs: &IndexMap<String, IndexMap<String, String>>, ) -> Result<serde_json::Value, WorkspaceResolverCreateError> { fn expand_entries( obj: serde_json::Map<String, serde_json::Value>, catalogs: &IndexMap<String, IndexMap<String, String>>, ) -> Result< serde_json::Map<String, serde_json::Value>, WorkspaceResolverCreateError, > { let mut result = serde_json::Map::with_capacity(obj.len()); for (key, value) in &obj { let maybe_catalog_name = value.as_str().and_then(|s| s.strip_prefix("catalog:")); let Some(catalog_name) = maybe_catalog_name else { result.insert(key.clone(), value.clone()); continue; }; let catalog_name = if catalog_name.is_empty() { "default" } else { catalog_name }; let name = key.strip_suffix('/').unwrap_or(key); let version_req = catalogs .get(catalog_name) .and_then(|catalog| catalog.get(name)) .ok_or_else(|| { WorkspaceResolverCreateError::CatalogPackageNotFound { name: name.to_string(), } })?; if key.ends_with('/') { result.insert( key.clone(), format!("npm:/{}@{}/", name, version_req).into(), ); } else { result.insert( key.clone(), format!("npm:{}@{}", name, version_req).into(), ); let key_with_slash = format!("{}/", key); if !obj.contains_key(&key_with_slash) { result.insert( key_with_slash, format!("npm:/{}@{}/", name, version_req).into(), ); } } } Ok(result) } let serde_json::Value::Object(mut map) = value else { return Ok(value); }; if let Some(serde_json::Value::Object(imports)) = map.remove("imports") { map.insert( "imports".to_string(), expand_entries(imports, catalogs)?.into(), ); } if let Some(serde_json::Value::Object(scopes)) = map.remove("scopes") { let mut expanded_scopes = serde_json::Map::with_capacity(scopes.len()); for (scope_key, scope_value) in scopes { let scope_value = match scope_value { serde_json::Value::Object(obj) => { expand_entries(obj, catalogs)?.into() } _ => scope_value, }; expanded_scopes.insert(scope_key, scope_value); } map.insert("scopes".to_string(), expanded_scopes.into()); } Ok(serde_json::Value::Object(map)) } // Builds the import map scope contributed by a workspace member or linked // package. The member's `imports` (not its `scopes`) are layered into the // synthetic map; this follows an external `importMap` file when the // member uses one. `to_import_map_value` already applies Deno's bare // specifier expansion for inline maps and leaves external maps untouched, // matching the import map standard, so no extra expansion is done here. fn child_import_map_config( sys: &impl FsRead, config: &ConfigFileRc, catalogs: &IndexMap<String, IndexMap<String, String>>, ) -> Result<import_map::ext::ImportMapConfig, WorkspaceResolverCreateError> { let (base_url, value) = match config.to_import_map_value(sys) { Ok(Some((specifier, value))) => (specifier.into_owned(), value), Ok(None) => ( config.specifier.clone(), serde_json::Value::Object(Default::default()), ), Err(err) => { log::debug!( "Ignoring import map for {}: {:#}", config.specifier, err ); ( config.specifier.clone(), serde_json::Value::Object(Default::default()), ) } }; let mut imports_only = serde_json::Map::with_capacity(1); if let serde_json::Value::Object(mut obj) = value && let Some(imports) = obj.remove("imports") { imports_only.insert("imports".to_string(), imports); } // catalog expansion only applies to inline imports, not to // external import map files let import_map_value = if config.is_an_import_map() { expand_catalog_specifiers(imports_only.into(), catalogs)? } else { imports_only.into() }; Ok(import_map::ext::ImportMapConfig { base_url, import_map_value, }) } let root_deno_json = workspace.root_deno_json(); let deno_jsons = workspace.resolver_deno_jsons().collect::<Vec<_>>(); // The base of the synthetic import map: either an explicitly specified map // (the `--import-map` flag or the root's external `importMap` file) or the // root deno.json's own import map. Either way, workspace member and linked // package scopes are layered on top so their bare specifiers resolve. let base_import_map_config = match specified_import_map { Some(SpecifiedImportMap { base_url, value }) => { import_map::ext::ImportMapConfig { base_url, import_map_value: value, } } None => { if !deno_jsons.iter().any(|p| p.is_package()) && !deno_jsons.iter().any(|c| { c.json.import_map.is_some() || c.json.scopes.is_some() || c.json.imports.is_some() || c .json .compiler_options .as_ref() .and_then(|v| v.as_object()?.get("rootDirs")?.as_array()) .is_some_and(|a| a.len() > 1) }) { // no configs have an import map and none are a package, so exit return Ok(None); } let (base_url, value) = match root_deno_json.as_ref() { Some(deno_json) => deno_json .to_import_map_value(sys) .map_err(|source| WorkspaceResolverCreateError::ImportMapFetch { referrer: deno_json.specifier.clone(), source: Box::new(source), })? .map(|(specifier, value)| (specifier.into_owned(), value)) .unwrap_or_else(|| { ( deno_json.specifier.clone(), serde_json::Value::Object(Default::default()), ) }), None => ( workspace.root_dir_url().join("deno.json").unwrap(), serde_json::Value::Object(Default::default()), ), }; // catalog expansion only applies to inline imports, not to // external import map files let value = if root_deno_json .as_ref() .is_some_and(|d| d.is_an_import_map()) { expand_catalog_specifiers(value, workspace.catalogs())? } else { value }; import_map::ext::ImportMapConfig { base_url, import_map_value: value, } } }; let child_import_map_configs = deno_jsons .iter() .filter(|f| { Some(&f.specifier) != root_deno_json.as_ref().map(|c| &c.specifier) }) .map(|config| { child_import_map_config(sys, config, workspace.catalogs()) }) .collect::<Result<Vec<_>, _>>()?; let (import_map_url, mut import_map) = ::import_map::ext::create_synthetic_import_map( base_import_map_config, child_import_map_configs, ); // When `jsrDepsInNodeModules` is enabled, install and resolve `jsr:` // dependencies through the npm machinery by rewriting them to their // npm-compat (`@jsr/scope__name`) form. This mirrors how pnpm/npm install // JSR packages and ensures they end up in `node_modules` (so external // tooling can find them) and resolve from disk (so `import.meta.dirname` // and bundled assets work). The local npm installer additionally writes a // `@jsr:registry` entry to `.npmrc` so that external tooling can resolve // the materialized packages. // // The rewrite is only meaningful when a `node_modules` directory is // actually in use: that is what materializes the packages, writes the // alias symlinks, and writes the `.npmrc`. Without it the rewrite would // resolve `jsr:` deps from the global npm cache with none of that, which // contradicts the option's "requires a `node_modules` directory" meaning. // So we couple the two and skip the rewrite when no `node_modules` // directory is enabled. let uses_node_modules_dir = match workspace.node_modules_dir().unwrap_or_default() { Some(mode) => mode.uses_node_modules_dir(), None => workspace.root_pkg_json().is_some(), }; if workspace.jsr_deps_in_node_modules() == Some(true) && uses_node_modules_dir { deno_config::import_map::rewrite_jsr_imports_to_npm(&mut import_map); } log::debug!( "Workspace config generated this import map {}", serde_json::to_string_pretty(&import_map).unwrap() ); Ok(Some(import_map::parse_from_value( import_map_url, import_map, )?)) } let maybe_import_map = resolve_import_map(&sys, workspace, options.specified_import_map)?; let jsr_pkgs = workspace.resolver_jsr_pkgs().collect::<Vec<_>>(); let pkg_jsons = workspace .resolver_pkg_jsons() .map(|(dir_url, pkg_json)| { let deps = pkg_json.resolve_local_package_json_deps(); ( dir_url.clone(), PkgJsonResolverFolderConfig { deps: deps.clone(), pkg_json: pkg_json.clone(), }, ) }) .collect::<BTreeMap<_, _>>(); let fs = CachedMetadataFs::new(sys, options.fs_cache_options); let compiler_options_resolver = CompilerOptionsResolverCellRc::default(); let sloppy_imports_resolver = new_rc(SloppyImportsResolver::new( fs, compiler_options_resolver.clone(), options.sloppy_imports_options, )); Ok(Self { workspace_root: workspace.root_dir_url().clone(), pkg_json_dep_resolution: options.pkg_json_dep_resolution, jsr_pkgs, maybe_import_map, pkg_jsons: FolderScopedMap::from_map(pkg_jsons), sloppy_imports_options: options.sloppy_imports_options, fs_cache_options: options.fs_cache_options, compiler_options_resolver, sloppy_imports_resolver, catalogs: workspace.catalogs().clone(), }) } /// Creates a new WorkspaceResolver from the specified import map and package.jsons. /// /// Generally, create this from a Workspace instead. #[allow(clippy::too_many_arguments, reason = "all arguments are needed")] pub fn new_raw( workspace_root: UrlRc, maybe_import_map: Option<ImportMap>, jsr_pkgs: Vec<ResolverWorkspaceJsrPackage>, pkg_jsons: Vec<PackageJsonRc>, pkg_json_dep_resolution: PackageJsonDepResolution, sloppy_imports_options: SloppyImportsOptions, fs_cache_options: FsCacheOptions, sys: TSys, catalogs: IndexMap<String, IndexMap<String, String>>, ) -> Self { let maybe_import_map = maybe_import_map.map(|import_map| ImportMapWithDiagnostics { import_map, diagnostics: Default::default(), }); let pkg_jsons = pkg_jsons .into_iter() .map(|pkg_json| { let deps = pkg_json.resolve_local_package_json_deps(); ( new_rc( url_from_directory_path(pkg_json.path.parent().unwrap()).unwrap(), ), PkgJsonResolverFolderConfig { deps: deps.clone(), pkg_json, }, ) }) .collect::<BTreeMap<_, _>>(); let fs = CachedMetadataFs::new(sys, fs_cache_options); let compiler_options_resolver = CompilerOptionsResolverCellRc::default(); let sloppy_imports_resolver = new_rc(SloppyImportsResolver::new( fs, compiler_options_resolver.clone(), sloppy_imports_options, )); Self { workspace_root, jsr_pkgs, maybe_import_map, pkg_jsons: FolderScopedMap::from_map(pkg_jsons), pkg_json_dep_resolution, sloppy_imports_options, fs_cache_options, compiler_options_resolver, sloppy_imports_resolver, catalogs, } } /// Prepare the workspace resolver for serialization /// /// The most significant preparation involves converting /// absolute paths into relative (based on `root_dir_url`). /// It also takes care of pre-serializing non-serde internal data. pub fn to_serializable( &self, root_dir_url: &Url, ) -> SerializableWorkspaceResolver<'_> { let root_dir_url = BaseUrl(root_dir_url); SerializableWorkspaceResolver { import_map: self.maybe_import_map().map(|i| { SerializedWorkspaceResolverImportMap { specifier: root_dir_url.make_relative_if_descendant(i.base_url()), json: Cow::Owned(i.to_json()), } }), jsr_pkgs: self .jsr_packages() .iter() .map(|pkg| SerializedResolverWorkspaceJsrPackage { relative_base: root_dir_url.make_relative_if_descendant(&pkg.base), name: Cow::Borrowed(&pkg.name), version: Cow::Borrowed(&pkg.version), exports: Cow::Borrowed(&pkg.exports), }) .collect(), package_jsons: self .package_jsons() .map(|pkg_json| { ( root_dir_url .make_relative_if_descendant(&pkg_json.specifier()) .into_owned(), serde_json::to_value(pkg_json).unwrap(), ) }) .collect(), pkg_json_resolution: self.pkg_json_dep_resolution(), sloppy_imports_options: self.sloppy_imports_options, fs_cache_options: self.fs_cache_options, catalogs: self.catalogs.clone(), } } /// Deserialize a `WorkspaceResolver` /// /// Deserialization of `WorkspaceResolver`s is made in two steps. First /// the serialized data must be deserialized in to `SerializableWorkspaceResolver` /// (usually with serde), and then this method converts it into a `WorkspaceResolver`. /// /// This second step involves mainly converting the relative paths within /// `SerializableWorkspaceResolver` into absolute paths using `root_dir_url`. pub fn try_from_serializable( root_dir_url: Url, serializable_workspace_resolver: SerializableWorkspaceResolver, sys: TSys, ) -> Result<Self, ImportMapError> { let import_map = match serializable_workspace_resolver.import_map { Some(import_map) => Some( import_map::parse_from_json_with_options( root_dir_url.join(&import_map.specifier).unwrap(), &import_map.json, import_map::ImportMapOptions { address_hook: None, expand_imports: true, }, )? .import_map, ), None => None, }; let pkg_jsons = serializable_workspace_resolver .package_jsons .into_iter() .map(|(relative_path, json)| { let path = url_to_file_path(&root_dir_url.join(&relative_path).unwrap()) .unwrap(); let pkg_json = deno_package_json::PackageJson::load_from_value(path, json).unwrap(); PackageJsonRc::new(pkg_json) }) .collect(); let jsr_packages = serializable_workspace_resolver .jsr_pkgs .into_iter() .map(|pkg| ResolverWorkspaceJsrPackage { is_link: false, // only used for enhancing the diagnostics, which are discarded when serializing base: root_dir_url.join(&pkg.relative_base).unwrap(), name: pkg.name.into_owned(), version: pkg.version.into_owned(), exports: pkg.exports.into_owned(), }) .collect(); Ok(Self::new_raw( UrlRc::new(root_dir_url), import_map, jsr_packages, pkg_jsons, serializable_workspace_resolver.pkg_json_resolution, serializable_workspace_resolver.sloppy_imports_options, serializable_workspace_resolver.fs_cache_options, sys, serializable_workspace_resolver.catalogs, )) } pub fn set_compiler_options_resolver( &self, value: CompilerOptionsResolverRc, ) { *self.compiler_options_resolver.write() = value; } pub fn maybe_import_map(&self) -> Option<&ImportMap> { self.maybe_import_map.as_ref().map(|c| &c.import_map) } pub fn package_jsons(&self) -> impl Iterator<Item = &PackageJsonRc> { self.pkg_jsons.values().map(|c| &c.pkg_json) } pub fn jsr_packages(&self) -> &[ResolverWorkspaceJsrPackage] { &self.jsr_pkgs } pub fn diagnostics(&self) -> Vec<WorkspaceResolverDiagnostic<'_>> { self .maybe_import_map .as_ref() .iter() .flat_map(|c| &c.diagnostics) .map(WorkspaceResolverDiagnostic::ImportMap) .collect() } pub fn resolve<'a>( &'a self, specifier: &str, referrer: &Url, resolution_kind: ResolutionKind, ) -> Result<MappedResolution<'a>, MappedResolutionError> { // 1.0. Attempt to resolve with the import map and normally first let compiler_options_resolver = self.compiler_options_resolver.read(); let compiler_options_data = compiler_options_resolver.for_specifier(referrer); let compiler_options_paths = compiler_options_data.paths(); let mut used_import_map = false; let resolve_result = if let Some(import_map) = &self.maybe_import_map { used_import_map = true; import_map .import_map .resolve(specifier, referrer) .map_err(MappedResolutionError::ImportMap) } else { deno_path_util::resolve_import(specifier, referrer) .map_err(MappedResolutionError::Specifier) }; let resolve_error = match resolve_result { Ok(mut resolved_specifier) => { // Collapse redundant path segments (e.g. `//`) introduced by // non-normalized specifiers like `.//a.js`. Without this, a cyclic // import graph keeps producing distinct specifiers that accumulate // slashes on every cycle and never dedupe, eventually exceeding the // OS file name length limit. collapse_redundant_file_specifier_segments(&mut resolved_specifier); let mut used_compiler_options_root_dirs = false; let mut sloppy_reason = None; if let Some((probed_specifier, probed_sloppy_reason)) = self .sloppy_imports_resolver .resolve(&resolved_specifier, referrer, resolution_kind) { resolved_specifier = probed_specifier; sloppy_reason = Some(probed_sloppy_reason); } else if resolution_kind.is_types() { // 1.1. Try to match the resolved specifier against // `compilerOptions.paths` if let Some(paths_result) = resolve_types_with_compiler_options_paths( resolved_specifier.as_str(), referrer, compiler_options_paths, &self.sloppy_imports_resolver, ) { let (probed_specifier, probed_sloppy_reason) = match paths_result { Ok(r) => r, Err(matched_key) => { return Err( MappedResolutionError::NotFoundInCompilerOptionsPaths( Box::new(NotFoundInCompilerOptionsPathsError { specifier: specifier.to_string(), referrer: referrer.clone(), matched_key, prior_resolution: Some(resolved_specifier), }), ), ); } }; { resolved_specifier = probed_specifier; sloppy_reason = probed_sloppy_reason; } // 1.2. Try to match the resolved specifier against // `compilerOptions.rootDirs` } else if let Some((probed_specifier, probed_sloppy_reason)) = resolve_types_with_compiler_options_root_dirs( &resolved_specifier, referrer, compiler_options_data.root_dirs(), &self.sloppy_imports_resolver, ) { used_compiler_options_root_dirs = true; resolved_specifier = probed_specifier; sloppy_reason = probed_sloppy_reason; } } return self.maybe_resolve_specifier_to_workspace_jsr_pkg( MappedResolution::Normal { specifier: resolved_specifier, sloppy_reason, used_import_map, used_compiler_options_root_dirs, maybe_diagnostic: None, }, ); } Err(err) => err, }; if resolve_error.is_unmapped_bare_specifier() { // 2.0. Try to resolve the bare specifier with `compilerOptions.paths` if resolution_kind.is_types() && let Some(paths_result) = resolve_types_with_compiler_options_paths( specifier, referrer, compiler_options_paths, &self.sloppy_imports_resolver, ) { let (probed_specifier, probed_sloppy_reason) = match paths_result { Ok(r) => r, Err(matched_key) => { return Err(MappedResolutionError::NotFoundInCompilerOptionsPaths( Box::new(NotFoundInCompilerOptionsPathsError { specifier: specifier.to_string(), referrer: referrer.clone(), matched_key, prior_resolution: None, }), )); } }; return self.maybe_resolve_specifier_to_workspace_jsr_pkg( MappedResolution::Normal { specifier: probed_specifier, sloppy_reason: probed_sloppy_reason, used_import_map: false, used_compiler_options_root_dirs: false, maybe_diagnostic: None, }, ); } // 2.1. Try to resolve the bare specifier to a workspace member or a // linked package. Linked packages resolve by bare name just like // workspace members do: a link is effectively a workspace member that // lives outside the workspace tree (a sibling directory, an absolute // path, or a private package not published to any registry). This is // also what lets a linked package's own files import a sibling linked // package by bare name. Workspace members come first in `jsr_pkgs`, so // they take precedence over a link with the same name. for member in self.jsr_pkgs.iter() { if let Some(path) = specifier.strip_prefix(&member.name) && (path.is_empty() || path.starts_with('/')) { let path = path.strip_prefix('/').unwrap_or(path); let pkg_req_ref = match JsrPackageReqReference::from_str(&format!( "jsr:{}{}/{}", member.name, member .version .as_ref() .map(|v| format!("@^{}", v)) .unwrap_or_else(String::new), path )) { Ok(pkg_req_ref) => pkg_req_ref, Err(_) => { // Ignore the error as it will be surfaced as a diagnostic // in workspace.diagnostics() routine. continue; } }; return self.resolve_workspace_jsr_pkg(member, pkg_req_ref); } } } // 3. Attempt to resolve from the package.json dependencies or imports. if specifier.starts_with('#') && specifier.len() > 1 { if let Some((_, pkg_json_folder)) = self.pkg_jsons.entry_for_specifier(referrer) { return Ok(MappedResolution::PackageJsonImport { pkg_json: &pkg_json_folder.pkg_json, }); } } else if self.pkg_json_dep_resolution == PackageJsonDepResolution::Enabled { for (_dir_url, pkg_json_folder) in self.pkg_jsons.entries_for_specifier(referrer) { for (bare_specifier, dep_result) in pkg_json_folder .deps .dependencies .iter() .chain(pkg_json_folder.deps.dev_dependencies.iter()) { if let Some(path) = specifier.strip_prefix(bare_specifier.as_str()) && (path.is_empty() || path.starts_with('/')) { let sub_path = path.strip_prefix('/').unwrap_or(path); return Ok(MappedResolution::PackageJson { pkg_json: &pkg_json_folder.pkg_json, alias: bare_specifier, sub_path: if sub_path.is_empty() { None } else { Some(sub_path.to_string()) }, dep_result, }); } } } // 4. Try to resolve to a workspace npm package if inside the workspace. if referrer.as_str().starts_with(self.workspace_root.as_str()) { for pkg_json_folder in self.pkg_jsons.values() { let Some(name) = &pkg_json_folder.pkg_json.name else { continue; }; let Some(path) = specifier.strip_prefix(name) else { continue; }; if path.is_empty() || path.starts_with('/') { let sub_path = path.strip_prefix('/').unwrap_or(path); return Ok(MappedResolution::WorkspaceNpmPackage { target_pkg_json: &pkg_json_folder.pkg_json, pkg_name: name, sub_path: if sub_path.is_empty() { None } else { Some(sub_path.to_string()) }, }); } } } } // wasn't found, so surface the initial resolve error Err(resolve_error) } fn maybe_resolve_specifier_to_workspace_jsr_pkg<'a>( &'a self, resolution: MappedResolution<'a>, ) -> Result<MappedResolution<'a>, MappedResolutionError> { let specifier = match resolution { MappedResolution::Normal { ref specifier, .. } => specifier, _ => return Ok(resolution), }; if specifier.scheme() != "jsr" { return Ok(resolution); } let mut maybe_diagnostic = None; if let Ok(package_req_ref) = JsrPackageReqReference::from_specifier(specifier) { for pkg in &self.jsr_pkgs { if pkg.name == package_req_ref.req().name { if let Some(version) = &pkg.version { if package_req_ref.req().version_req.matches(version) { return self.resolve_workspace_jsr_pkg(pkg, package_req_ref); } else { maybe_diagnostic = Some(Box::new( MappedResolutionDiagnostic::ConstraintNotMatchedLocalVersion { is_link: pkg.is_link, reference: package_req_ref.clone(), local_version: version.clone(), }, )); } } else { // always resolve to workspace packages with no version return self.resolve_workspace_jsr_pkg(pkg, package_req_ref); } } } } Ok(match resolution { MappedResolution::Normal { specifier, sloppy_reason, used_import_map, used_compiler_options_root_dirs, .. } => MappedResolution::Normal { specifier, sloppy_reason, used_import_map, used_compiler_options_root_dirs, maybe_diagnostic, }, _ => return Ok(resolution), }) } fn resolve_workspace_jsr_pkg<'a>( &'a self, pkg: &'a ResolverWorkspaceJsrPackage, pkg_req_ref: JsrPackageReqReference, ) -> Result<MappedResolution<'a>, MappedResolutionError> { let export_name = pkg_req_ref.export_name(); match pkg.exports.get(export_name.as_ref()) { Some(sub_path) => match pkg.base.join(sub_path) { Ok(specifier) => Ok(MappedResolution::WorkspaceJsrPackage { specifier, pkg_req_ref, }), Err(err) => Err( WorkspaceResolveError::InvalidExportPath { base: pkg.base.clone(), sub_path: sub_path.to_string(), error: err, } .into(), ), }, None => Err( WorkspaceResolveError::UnknownExport { package_name: pkg.name.clone(), export_name: export_name.to_string(), exports: pkg.exports.keys().cloned().collect(), } .into(), ), } } pub fn resolve_workspace_pkg_json_folder_for_npm_specifier( &self, pkg_req: &PackageReq, ) -> Option<&Path> { if pkg_req.version_req.tag().is_some() { return None; } self .resolve_workspace_pkg_json_folder_for_pkg_json_dep( &pkg_req.name, &PackageJsonDepWorkspaceReq::VersionReq(pkg_req.version_req.clone()), ) .ok() } pub fn resolve_workspace_pkg_json_folder_for_pkg_json_dep( &self, name: &str, workspace_version_req: &PackageJsonDepWorkspaceReq, ) -> Result<&Path, WorkspaceResolvePkgJsonFolderError> { // this is not conditional on pkg_json_dep_resolution because we want // to be able to do this resolution to figure out mapping an npm specifier // to a workspace folder when using BYONM let pkg_json = self .package_jsons() .find(|p| p.name.as_deref() == Some(name)); let Some(pkg_json) = pkg_json else { return Err( WorkspaceResolvePkgJsonFolderErrorKind::NotFound(name.to_string()) .into(), ); }; match workspace_version_req { PackageJsonDepWorkspaceReq::VersionReq(version_req) => { match version_req.inner() { RangeSetOrTag::RangeSet(_) => { match pkg_json .version .as_ref() .and_then(|v| Version::parse_from_npm(v).ok()) { Some(version) => { // Match prerelease versions that fall within the range bounds // too. A workspace member with a prerelease version (e.g. // `0.40.0-pre`) should still resolve to the local package // instead of being rejected, since it is provided explicitly // rather than picked from the registry. if crate::npm::version_req_matches_including_pre( version_req, &version, ) { Ok(pkg_json.dir_path()) } else { Err( WorkspaceResolvePkgJsonFolderErrorKind::VersionNotSatisfied( version_req.clone(), version, ) .into(), ) } } _ => { // just match it Ok(pkg_json.dir_path()) } } } RangeSetOrTag::Tag(_) => { // always match tags Ok(pkg_json.dir_path()) } } } PackageJsonDepWorkspaceReq::Tilde | PackageJsonDepWorkspaceReq::Caret => { // always match tilde and caret requirements Ok(pkg_json.dir_path()) } } } pub fn catalogs(&self) -> &IndexMap<String, IndexMap<String, String>> { &self.catalogs } pub fn resolve_catalog_dep( &self, name: &str, catalog_name: &str, ) -> Option<PackageReq> { let catalog = self.catalogs.get(catalog_name)?; let version_req_str = catalog.get(name)?; let version_req = VersionReq::parse_from_npm(version_req_str).ok()?; Some(PackageReq { name: name.into(), version_req, }) } pub fn pkg_json_dep_resolution(&self) -> PackageJsonDepResolution { self.pkg_json_dep_resolution } pub fn sloppy_imports_enabled(&self) -> bool { match self.sloppy_imports_options { SloppyImportsOptions::Enabled => true, SloppyImportsOptions::Unspecified => false, } } pub fn has_compiler_options_root_dirs(&self) -> bool { self.compiler_options_resolver.read().has_root_dirs() } } /// Collapses redundant path segments (such as the empty segment produced by a /// `//` in the specifier) in a `file:` URL. Other schemes are left untouched. /// /// `deno_path_util::normalize_path` intentionally leaves a lone `//` alone, so /// we rely on `Path::components()` here, which discards repeated separators and /// `.` segments while preserving the rest of the path verbatim. fn collapse_redundant_file_specifier_segments(specifier: &mut Url) { if specifier.scheme() != "file" { return; } // Quick check to avoid the path round-trip for the common case. if !specifier.path().contains("//") { return; } let Ok(path) = url_to_file_path(specifier) else { return; }; let collapsed = path.components().collect::<PathBuf>(); // Note: `PathBuf` compares by component, so the raw `OsStr` must be compared // here to detect a difference in the separators that were collapsed. if collapsed.as_os_str() == path.as_os_str() { return; } let Ok(mut collapsed_specifier) = url_from_file_path(&collapsed) else { return; }; collapsed_specifier.set_query(specifier.query()); collapsed_specifier.set_fragment(specifier.fragment()); *specifier = collapsed_specifier; } #[derive(Deserialize, Serialize)] pub struct SerializedWorkspaceResolverImportMap<'a> { #[serde(borrow)] pub specifier: Cow<'a, str>, #[serde(borrow)] pub json: Cow<'a, str>, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SerializedResolverWorkspaceJsrPackage<'a> { #[serde(borrow)] pub relative_base: Cow<'a, str>, #[serde(borrow)] pub name: Cow<'a, str>, pub version: Cow<'a, Option<Version>>, pub exports: Cow<'a, IndexMap<String, String>>, } #[derive(Deserialize, Serialize)] pub struct SerializableWorkspaceResolver<'a> { #[serde(borrow)] pub import_map: Option<SerializedWorkspaceResolverImportMap<'a>>, #[serde(borrow)] pub jsr_pkgs: Vec<SerializedResolverWorkspaceJsrPackage<'a>>, pub package_jsons: Vec<(String, serde_json::Value)>, pub pkg_json_resolution: PackageJsonDepResolution, pub sloppy_imports_options: SloppyImportsOptions, pub fs_cache_options: FsCacheOptions, #[serde(default)] pub catalogs: IndexMap<String, IndexMap<String, String>>, } #[derive(Debug, Clone, Copy)] struct BaseUrl<'a>(&'a Url); impl BaseUrl<'_> { fn make_relative_if_descendant<'a>(&self, target: &'a Url) -> Cow<'a, str> { if target.scheme() != "file" { return Cow::Borrowed(target.as_str()); } match self.0.make_relative(target) { Some(relative) => { if relative.starts_with("../") { Cow::Borrowed(target.as_str()) } else { Cow::Owned(relative) } } None => Cow::Borrowed(target.as_str()), } } } #[allow(clippy::disallowed_types, reason = "wraps Arc directly as the Rc type")] #[derive(Debug, Default, Clone)] pub struct WorkspaceNpmLinkPackagesRc( pub std::sync::Arc<HashMap<PackageName, Vec<NpmPackageVersionInfo>>>, ); impl WorkspaceNpmLinkPackagesRc { pub fn from_workspace(workspace: &Workspace) -> Self { let mut entries: HashMap<PackageName, Vec<NpmPackageVersionInfo>> = HashMap::new(); for folder in workspace.link_folders().values() { // A linked folder that also has a deno.json is a Deno/JSR link, not an // npm link. Some packages ship a package.json only for other runtimes // (e.g. Bun), so counting it as an npm link would wrongly require a // node_modules directory. if folder.deno_json.is_some() { continue; } let Some(pkg_json) = folder.pkg_json.as_ref() else { continue; }; let Some(name) = pkg_json.name.as_ref() else { log::warn!( "{} Link package ignored because package.json was missing name field.\n at {}", colors::yellow("Warning"), pkg_json.path.display(), ); continue; }; match pkg_json_to_version_info(pkg_json) { Ok(version_info) => { let entry = entries.entry(PackageName::from_str(name)).or_default(); entry.push(version_info); } Err(err) => { log::warn!( "{} {}\n at {}", colors::yellow("Warning"), err, pkg_json.path.display(), ); } } } Self(deno_maybe_sync::new_arc(entries)) } } #[derive(Debug, Error)] enum PkgJsonToVersionInfoError { #[error( "Linked package ignored because package.json was missing version field." )] VersionMissing, #[error( "Linked package ignored because package.json version field could not be parsed." )] VersionInvalid { #[source] source: deno_semver::npm::NpmVersionParseError, }, } fn pkg_json_to_version_info( pkg_json: &deno_package_json::PackageJson, ) -> Result<NpmPackageVersionInfo, PkgJsonToVersionInfoError> { fn parse_deps( deps: Option<&IndexMap<String, String>>, ) -> HashMap<StackString, StackString> { deps .map(|d| { d.into_iter() .map(|(k, v)| (StackString::from_str(k), StackString::from_str(v))) .collect() }) .unwrap_or_default() } fn parse_small_stack_string_array(v: &[String]) -> Vec<SmallStackString> { v.iter().map(|s| SmallStackString::from_str(s)).collect() } fn parse_stack_string_array(v: &[String]) -> Vec<StackString> { v.iter().map(|s| StackString::from_str(s)).collect() } let Some(version) = &pkg_json.version else { return Err(PkgJsonToVersionInfoError::VersionMissing); }; let version = Version::parse_from_npm(version) .map_err(|source| PkgJsonToVersionInfoError::VersionInvalid { source })?; Ok(NpmPackageVersionInfo { version, exports: pkg_json .exports .as_ref() .map(deno_npm::registry::NpmPackageExportKeys::from_exports_object), dist: None, bin: pkg_json .bin .as_ref() .and_then(|v| serde_json::from_value(v.clone()).ok()), dependencies: parse_deps(pkg_json.dependencies.as_ref()), bundle_dependencies: pkg_json .bundle_dependencies .as_ref() .map(|d| parse_stack_string_array(d)) .unwrap_or_default(), bundled_dependencies: Vec::new(), optional_dependencies: parse_deps(pkg_json.optional_dependencies.as_ref()), peer_dependencies: parse_deps(pkg_json.peer_dependencies.as_ref()), peer_dependencies_meta: pkg_json .peer_dependencies_meta .clone() .and_then(|m| serde_json::from_value(m).ok()) .unwrap_or_default(), os: pkg_json .os .as_deref() .map(parse_small_stack_string_array) .unwrap_or_default(), cpu: pkg_json .cpu .as_deref() .map(parse_small_stack_string_array) .unwrap_or_default(), scripts: pkg_json .scripts .as_ref() .map(|scripts| { scripts .iter() .map(|(k, v)| (SmallStackString::from_str(k), v.clone())) .collect() }) .unwrap_or_default(), has_install_script: None, // not worth increasing memory for showing a deprecated // message for linked packages deprecated: None, // linked/workspace packages from a package.json carry no registry // trust signals npm_user: None, }) } #[cfg(test)] mod test { use std::path::Path; use std::path::PathBuf; use deno_config::workspace::WorkspaceDirectory; use deno_config::workspace::WorkspaceDirectoryRc; use deno_config::workspace::WorkspaceDiscoverOptions; use deno_config::workspace::WorkspaceDiscoverStart; use deno_npm::registry::NpmPeerDependencyMeta; use deno_path_util::url_from_directory_path; use deno_path_util::url_from_file_path; use deno_semver::VersionReq; use node_resolver::DenoIsBuiltInNodeModuleChecker; use node_resolver::NodeResolver; use node_resolver::NodeResolverOptions; use node_resolver::NpmPackageFolderResolver; use node_resolver::PackageJsonResolver; use node_resolver::cache::NodeResolutionSys; use node_resolver::errors::PackageFolderResolveError; use node_resolver::errors::PackageFolderResolveErrorKind; use node_resolver::errors::PackageNotFoundError; use serde_json::json; use sys_traits::FsCanonicalize; use sys_traits::impls::InMemorySys; use url::Url; use super::*; use crate::deno_json::CompilerOptionsResolver; use crate::factory::ConfigDiscoveryOption; use crate::npm::CreateInNpmPkgCheckerOptions; use crate::npm::DenoInNpmPackageChecker; use crate::npm::NpmResolverSys; pub struct UnreachableSys; impl sys_traits::BaseFsMetadata for UnreachableSys { type Metadata = sys_traits::impls::RealFsMetadata; #[doc(hidden)] fn base_fs_metadata( &self, _path: &Path, ) -> std::io::Result<Self::Metadata> { unreachable!() } #[doc(hidden)] fn base_fs_symlink_metadata( &self, _path: &Path, ) -> std::io::Result<Self::Metadata> { unreachable!() } } impl sys_traits::BaseFsRead for UnreachableSys { fn base_fs_read( &self, _path: &Path, ) -> std::io::Result<Cow<'static, [u8]>> { unreachable!() } } fn root_dir() -> PathBuf { if cfg!(windows) { PathBuf::from("C:\\Users\\user") } else { PathBuf::from("/home/user") } } #[derive(Debug)] struct TestNpmPackageFolderResolver; impl NpmPackageFolderResolver for TestNpmPackageFolderResolver { fn resolve_package_folder_from_package( &self, specifier: &str, referrer: &node_resolver::UrlOrPathRef, ) -> Result<PathBuf, PackageFolderResolveError> { Err(PackageFolderResolveError(Box::new( PackageFolderResolveErrorKind::PackageNotFound(PackageNotFoundError { package_name: specifier.to_string(), referrer: referrer.display(), referrer_extra: None, }), ))) } fn resolve_types_package_folder( &self, _types_package_name: &str, _maybe_package_version: Option<&Version>, _maybe_referrer: Option<&node_resolver::UrlOrPathRef>, ) -> Option<PathBuf> { None } } #[allow(clippy::disallowed_types, reason = "ok in tests")] fn setup_node_resolver<TSys: NpmResolverSys>( sys: &TSys, ) -> crate::deno_json::TsConfigNodeResolver<TSys, TestNpmPackageFolderResolver> { let package_json_resolver = new_rc(PackageJsonResolver::new(sys.clone(), None)); NodeResolver::new( DenoInNpmPackageChecker::new(CreateInNpmPkgCheckerOptions::Byonm), DenoIsBuiltInNodeModuleChecker, TestNpmPackageFolderResolver, package_json_resolver, NodeResolutionSys::new(sys.clone(), None), NodeResolverOptions::default(), ) } #[test] fn collapse_redundant_segments_ignores_non_file_schemes() { // The scheme guard must win even when the path itself contains a `//`, so // non-file specifiers are returned completely untouched. for url in [ "https://example.com//a//b.js", "http://deno.land/x//mod.ts", "data:text/plain,//hello", ] { let mut specifier = Url::parse(url).unwrap(); let original = specifier.clone(); collapse_redundant_file_specifier_segments(&mut specifier); assert_eq!(specifier, original, "{url} should be left unchanged"); } } #[cfg(not(windows))] #[test] fn collapse_redundant_segments_file() { fn collapse(url: &str) -> String { let mut specifier = Url::parse(url).unwrap(); collapse_redundant_file_specifier_segments(&mut specifier); specifier.to_string() } // The empty segment produced by `.//a.js` (the regression in #23821) is // collapsed so cyclic imports dedupe instead of growing a slash per cycle. assert_eq!(collapse("file:///dir//a.js"), "file:///dir/a.js"); // Multiple runs of redundant separators are all collapsed. assert_eq!(collapse("file:///a////b//c.js"), "file:///a/b/c.js"); // A `.` segment next to the redundant slash is collapsed too. assert_eq!(collapse("file:///a/.//b.js"), "file:///a/b.js"); // An already-normalized specifier is returned byte-for-byte unchanged. assert_eq!(collapse("file:///a/b.js"), "file:///a/b.js"); // Note: there is intentionally no `..` case here. The `Url` type always // resolves `.`/`..` dot segments when a specifier is constructed, so the // only redundancy that can reach this helper is empty (`//`) segments; // `Path::components()` never resolves `..`, so the collapse stays // symlink-safe regardless. // Query and fragment survive the path round-trip. assert_eq!( collapse("file:///a//b.js?foo=1#bar"), "file:///a/b.js?foo=1#bar" ); // Percent-encoded characters survive the decode/re-encode round-trip. assert_eq!(collapse("file:///a//b%20c.js"), "file:///a/b%20c.js"); } #[cfg(windows)] #[test] fn collapse_redundant_segments_file_windows() { fn collapse(url: &str) -> String { let mut specifier = Url::parse(url).unwrap(); collapse_redundant_file_specifier_segments(&mut specifier); specifier.to_string() } // The drive-letter prefix is preserved while redundant separators after it // are collapsed. assert_eq!(collapse("file:///C:/dir//a.js"), "file:///C:/dir/a.js"); assert_eq!(collapse("file:///C:/a////b//c.js"), "file:///C:/a/b/c.js"); assert_eq!(collapse("file:///C:/a/b.js"), "file:///C:/a/b.js"); assert_eq!( collapse("file:///C:/a//b.js?foo=1#bar"), "file:///C:/a/b.js?foo=1#bar" ); } #[test] fn pkg_json_resolution() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": [ "a", "b", "c", ] }), ); sys.fs_insert_json( root_dir().join("a/deno.json"), json!({ "imports": { "b": "./index.js", }, }), ); sys.fs_insert_json( root_dir().join("b/package.json"), json!({ "dependencies": { "pkg": "npm:pkg@^1.0.0", }, }), ); sys.fs_insert_json( root_dir().join("c/package.json"), json!({ "name": "pkg", "version": "0.5.0" }), ); let workspace = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace); assert_eq!(resolver.diagnostics(), Vec::new()); let resolve = |name: &str, referrer: &str| { resolver.resolve( name, &url_from_file_path(&deno_path_util::normalize_path(Cow::Owned( root_dir().join(referrer), ))) .unwrap(), ResolutionKind::Execution, ) }; match resolve("pkg", "b/index.js").unwrap() { MappedResolution::PackageJson { alias, sub_path, dep_result, .. } => { assert_eq!(alias, "pkg"); assert_eq!(sub_path, None); dep_result.as_ref().unwrap(); } value => unreachable!("{:?}", value), } match resolve("pkg/sub-path", "b/index.js").unwrap() { MappedResolution::PackageJson { alias, sub_path, dep_result, .. } => { assert_eq!(alias, "pkg"); assert_eq!(sub_path.unwrap(), "sub-path"); dep_result.as_ref().unwrap(); } value => unreachable!("{:?}", value), } // pkg is not a dependency in this folder, so it should resolve // to the workspace member match resolve("pkg", "index.js").unwrap() { MappedResolution::WorkspaceNpmPackage { pkg_name, sub_path, target_pkg_json, } => { assert_eq!(pkg_name, "pkg"); assert_eq!(sub_path, None); assert_eq!(target_pkg_json.dir_path(), root_dir().join("c")); } _ => unreachable!(), } match resolve("pkg/sub-path", "index.js").unwrap() { MappedResolution::WorkspaceNpmPackage { pkg_name, sub_path, target_pkg_json, } => { assert_eq!(pkg_name, "pkg"); assert_eq!(sub_path.unwrap(), "sub-path"); assert_eq!(target_pkg_json.dir_path(), root_dir().join("c")); } _ => unreachable!(), } // won't resolve the package outside the workspace assert!(resolve("pkg", "../outside-workspace.js").is_err()); } #[test] fn single_pkg_no_import_map() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "name": "@scope/pkg", "version": "1.0.0", "exports": "./mod.ts" }), ); let workspace = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace); assert_eq!(resolver.diagnostics(), Vec::new()); let result = resolver .resolve( "@scope/pkg", &url_from_file_path(&root_dir().join("file.ts")).unwrap(), ResolutionKind::Execution, ) .unwrap(); match result { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!( specifier, url_from_file_path(&root_dir().join("mod.ts")).unwrap() ); } _ => unreachable!(), } } #[test] fn resolve_workspace_pkg_json_folder() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": [ "a", "b", "no-version", "pre" ] }), ); sys.fs_insert_json( root_dir().join("a/package.json"), json!({ "name": "@scope/a", "version": "1.0.0", }), ); sys.fs_insert_json( root_dir().join("b/package.json"), json!({ "name": "@scope/b", "version": "2.0.0", }), ); sys.fs_insert_json( root_dir().join("no-version/package.json"), json!({ "name": "@scope/no-version", }), ); sys.fs_insert_json( root_dir().join("pre/package.json"), json!({ "name": "@scope/pre", "version": "0.40.0-pre", }), ); let workspace = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace); // resolve for pkg json dep { let resolve = |name: &str, req: &str| { resolver.resolve_workspace_pkg_json_folder_for_pkg_json_dep( name, &PackageJsonDepWorkspaceReq::VersionReq( VersionReq::parse_from_npm(req).unwrap(), ), ) }; assert_eq!( resolve("non-existent", "*").map_err(|e| e.into_kind()), Err(WorkspaceResolvePkgJsonFolderErrorKind::NotFound( "non-existent".to_string() )) ); assert_eq!( resolve("@scope/a", "6").map_err(|e| e.into_kind()), Err(WorkspaceResolvePkgJsonFolderErrorKind::VersionNotSatisfied( VersionReq::parse_from_npm("6").unwrap(), Version::parse_from_npm("1.0.0").unwrap(), )) ); assert_eq!(resolve("@scope/a", "1").unwrap(), root_dir().join("a")); assert_eq!(resolve("@scope/a", "*").unwrap(), root_dir().join("a")); assert_eq!( resolve("@scope/a", "workspace").unwrap(), root_dir().join("a") ); assert_eq!(resolve("@scope/b", "2").unwrap(), root_dir().join("b")); // just match any tags with the workspace assert_eq!(resolve("@scope/a", "latest").unwrap(), root_dir().join("a")); // a workspace member with a prerelease version still resolves, even // though npm semver ranges normally exclude prereleases assert_eq!(resolve("@scope/pre", "*").unwrap(), root_dir().join("pre")); assert_eq!( resolve("@scope/pre", "^0.40.0-pre").unwrap(), root_dir().join("pre") ); // match any version for a pkg with no version assert_eq!( resolve("@scope/no-version", "1").unwrap(), root_dir().join("no-version") ); assert_eq!( resolve("@scope/no-version", "20").unwrap(), root_dir().join("no-version") ); } // resolve for specifier { let resolve = |pkg_req: &str| { resolver.resolve_workspace_pkg_json_folder_for_npm_specifier( &PackageReq::from_str(pkg_req).unwrap(), ) }; assert_eq!(resolve("non-existent@*"), None); assert_eq!( resolve("@scope/no-version@1").unwrap(), root_dir().join("no-version") ); // a bare `npm:` specifier (`*`) resolves a prerelease workspace member assert_eq!(resolve("@scope/pre@*").unwrap(), root_dir().join("pre")); // won't match for tags assert_eq!(resolve("@scope/a@workspace"), None); assert_eq!(resolve("@scope/a@latest"), None); } } #[test] fn resolve_workspace_pkg_json_workspace_deno_json_import_map() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("package.json"), json!({ "workspaces": ["*"] }), ); sys.fs_insert_json( root_dir().join("a/package.json"), json!({ "name": "@scope/a", "version": "1.0.0", }), ); sys.fs_insert_json( root_dir().join("a/deno.json"), json!({ "name": "@scope/jsr-pkg", "version": "1.0.0", "exports": "./mod.ts" }), ); let workspace = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace); { let resolution = resolver .resolve( "@scope/jsr-pkg", &url_from_file_path(&root_dir().join("b.ts")).unwrap(), ResolutionKind::Execution, ) .unwrap(); match resolution { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!( specifier, url_from_file_path(&root_dir().join("a/mod.ts")).unwrap() ); } _ => unreachable!(), } } { let resolution_err = resolver .resolve( "@scope/jsr-pkg/not-found-export", &url_from_file_path(&root_dir().join("b.ts")).unwrap(), ResolutionKind::Execution, ) .unwrap_err(); match resolution_err { MappedResolutionError::Workspace( WorkspaceResolveError::UnknownExport { package_name, export_name, exports, }, ) => { assert_eq!(package_name, "@scope/jsr-pkg"); assert_eq!(export_name, "./not-found-export"); assert_eq!(exports, vec!["."]); } _ => unreachable!(), } } } #[test] fn auto_link_external_import_map() { // A project that references an external package by relative path should // resolve that package's own bare specifier imports against its own // import map, without needing a `links` entry (issue #26764). let sys = InMemorySys::default(); let repo2 = root_dir().join("repo2"); let repo1 = root_dir().join("repo1"); sys.fs_insert_json( repo2.join("deno.json"), json!({ "imports": { "@jpravetz/bmod": "../repo1/bmod/mod.ts", }, }), ); sys.fs_insert_json( repo1.join("bmod/deno.json"), json!({ "imports": { "@scope/amod": "../amod/mod.ts", }, }), ); let workspace = workspace_at_start_dir(&sys, &repo2); let resolver = create_resolver(&workspace); let referrer = url_from_file_path(&repo1.join("bmod/mod.ts")).unwrap(); let resolution = resolver .resolve("@scope/amod", &referrer, ResolutionKind::Execution) .unwrap(); let MappedResolution::Normal { specifier, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), url_from_file_path(&repo1.join("amod/mod.ts")) .unwrap() .as_str() ); } #[test] fn auto_link_external_import_map_file() { // Same as the basic case, but the root project points at an external // `importMap` file instead of an inline `imports` map. The path entry // there resolves relative to the import map file, and discovery must still // find repo1's deno.json (issue #26764). let sys = InMemorySys::default(); let repo2 = root_dir().join("repo2"); let repo1 = root_dir().join("repo1"); sys.fs_insert_json( repo2.join("deno.json"), json!({ "importMap": "./import_map.json", }), ); sys.fs_insert_json( repo2.join("import_map.json"), json!({ "imports": { "@jpravetz/bmod": "../repo1/bmod/mod.ts", }, }), ); sys.fs_insert_json( repo1.join("bmod/deno.json"), json!({ "imports": { "@scope/amod": "../amod/mod.ts", }, }), ); let workspace = workspace_at_start_dir(&sys, &repo2); let resolver = create_resolver_with_sys(sys.clone(), &workspace); let referrer = url_from_file_path(&repo1.join("bmod/mod.ts")).unwrap(); let resolution = resolver .resolve("@scope/amod", &referrer, ResolutionKind::Execution) .unwrap(); let MappedResolution::Normal { specifier, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), url_from_file_path(&repo1.join("amod/mod.ts")) .unwrap() .as_str() ); } #[test] fn auto_link_external_import_map_transitive() { // Auto-discovery follows path import entries transitively across several // external packages. let sys = InMemorySys::default(); let repo2 = root_dir().join("repo2"); let repo1 = root_dir().join("repo1"); let repo3 = root_dir().join("repo3"); sys.fs_insert_json( repo2.join("deno.json"), json!({ "imports": { "@a/bmod": "../repo1/bmod/mod.ts" }, }), ); sys.fs_insert_json( repo1.join("bmod/deno.json"), json!({ "imports": { "@a/cmod": "../../repo3/cmod/mod.ts" }, }), ); sys.fs_insert_json( repo3.join("cmod/deno.json"), json!({ "imports": { "@a/dmod": "./sub/mod.ts" }, }), ); let workspace = workspace_at_start_dir(&sys, &repo2); let resolver = create_resolver(&workspace); let referrer = url_from_file_path(&repo3.join("cmod/mod.ts")).unwrap(); let resolution = resolver .resolve("@a/dmod", &referrer, ResolutionKind::Execution) .unwrap(); let MappedResolution::Normal { specifier, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), url_from_file_path(&repo3.join("cmod/sub/mod.ts")) .unwrap() .as_str() ); } #[test] fn auto_link_external_import_map_cycle() { // Mutually-referencing external packages must terminate discovery and // still resolve both directions. let sys = InMemorySys::default(); let repo2 = root_dir().join("repo2"); let repo1 = root_dir().join("repo1"); sys.fs_insert_json( repo2.join("deno.json"), json!({ "imports": { "@x/bmod": "../repo1/bmod/mod.ts" }, }), ); sys.fs_insert_json( repo1.join("bmod/deno.json"), json!({ "imports": { "@x/emod": "../emod/mod.ts" }, }), ); sys.fs_insert_json( repo1.join("emod/deno.json"), json!({ "imports": { "@x/bmod": "../bmod/mod.ts" }, }), ); let workspace = workspace_at_start_dir(&sys, &repo2); let resolver = create_resolver(&workspace); let resolve = |specifier: &str, referrer: &Path| { let referrer = url_from_file_path(referrer).unwrap(); let resolution = resolver .resolve(specifier, &referrer, ResolutionKind::Execution) .unwrap(); match resolution { MappedResolution::Normal { specifier, .. } => specifier, other => unreachable!("{:#?}", other), } }; assert_eq!( resolve("@x/emod", &repo1.join("bmod/mod.ts")).as_str(), url_from_file_path(&repo1.join("emod/mod.ts")) .unwrap() .as_str() ); assert_eq!( resolve("@x/bmod", &repo1.join("emod/mod.ts")).as_str(), url_from_file_path(&repo1.join("bmod/mod.ts")) .unwrap() .as_str() ); } #[test] fn root_member_imports_and_scopes() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["member"], "imports": { "@scope/pkg": "jsr:@scope/pkg@1", }, "scopes": { "https://deno.land/x/": { "@scope/pkg": "jsr:@scope/pkg@2", }, }, }), ); // Overrides `rootDirs` from workspace root. sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "imports": { "@scope/pkg": "jsr:@scope/pkg@3", }, // will ignore this scopes because it's not in the root "scopes": { "https://deno.land/x/other": { "@scope/pkg": "jsr:@scope/pkg@4", }, }, }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys.clone(), super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); assert_eq!( serde_json::from_str::<serde_json::Value>( &resolver.maybe_import_map().unwrap().to_json() ) .unwrap(), json!({ "imports": { "@scope/pkg": "jsr:@scope/pkg@1", "@scope/pkg/": "jsr:/@scope/pkg@1/", }, "scopes": { "https://deno.land/x/": { "@scope/pkg": "jsr:@scope/pkg@2", "@scope/pkg/": "jsr:/@scope/pkg@2/", }, "./member/": { "@scope/pkg": "jsr:@scope/pkg@3", "@scope/pkg/": "jsr:/@scope/pkg@3/", }, }, }), ); } #[test] fn resolve_sloppy_imports() { let sys = InMemorySys::default(); let root_url = url_from_file_path( &sys_traits::impls::RealSys.fs_canonicalize("/").unwrap(), ) .unwrap(); let fs = CachedMetadataFs::new(sys.clone(), FsCacheOptions::Enabled); let sloppy_imports_resolver = SloppyImportsResolver::new( fs, Default::default(), SloppyImportsOptions::Enabled, ); let referrer = root_url.join("main.ts").unwrap(); // scenarios like resolving ./example.js to ./example.ts for (file_from, file_to) in [ ("file1.js", "file1.ts"), ("file2.js", "file2.tsx"), ("file3.mjs", "file3.mts"), ] { let specifier = root_url.join(file_to).unwrap(); sys.fs_insert(url_to_file_path(&specifier).unwrap(), ""); let sloppy_specifier = root_url.join(file_from).unwrap(); assert_eq!( sloppy_imports_resolver.resolve( &specifier, &referrer, ResolutionKind::Execution ), None, ); assert_eq!( sloppy_imports_resolver.resolve( &sloppy_specifier, &referrer, ResolutionKind::Execution ), Some((specifier, SloppyImportsResolutionReason::JsToTs)), ); } // no extension scenarios for file in [ "file10.js", "file11.ts", "file12.js", "file13.tsx", "file14.jsx", "file15.mjs", "file16.mts", ] { let specifier = root_url.join(file).unwrap(); sys.fs_insert(url_to_file_path(&specifier).unwrap(), ""); let sloppy_specifier = root_url.join(file.split_once('.').unwrap().0).unwrap(); assert_eq!( sloppy_imports_resolver.resolve( &specifier, &referrer, ResolutionKind::Execution ), None, ); assert_eq!( sloppy_imports_resolver.resolve( &sloppy_specifier, &referrer, ResolutionKind::Execution ), Some((specifier, SloppyImportsResolutionReason::NoExtension)), ); } // .ts and .js exists, .js specified (goes to specified) { let ts_specifier = root_url.join("ts_and_js.ts").unwrap(); sys.fs_insert(url_to_file_path(&ts_specifier).unwrap(), ""); let js_specifier = root_url.join("ts_and_js.js").unwrap(); sys.fs_insert(url_to_file_path(&js_specifier).unwrap(), ""); assert_eq!( sloppy_imports_resolver.resolve( &js_specifier, &referrer, ResolutionKind::Execution ), None, ); } // only js exists, .js specified { let specifier = root_url.join("js_only.js").unwrap(); sys.fs_insert(url_to_file_path(&specifier).unwrap(), ""); assert_eq!( sloppy_imports_resolver.resolve( &specifier, &referrer, ResolutionKind::Execution ), None, ); assert_eq!( sloppy_imports_resolver.resolve( &specifier, &referrer, ResolutionKind::Types ), None, ); } // resolving a directory to an index file { let specifier = root_url.join("routes/index.ts").unwrap(); sys.fs_insert(url_to_file_path(&specifier).unwrap(), ""); let sloppy_specifier = root_url.join("routes").unwrap(); assert_eq!( sloppy_imports_resolver.resolve( &sloppy_specifier, &referrer, ResolutionKind::Execution ), Some((specifier, SloppyImportsResolutionReason::Directory)), ); } // both a directory and a file with specifier is present { let specifier = root_url.join("api.ts").unwrap(); sys.fs_insert(url_to_file_path(&specifier).unwrap(), ""); let bar_specifier = root_url.join("api/bar.ts").unwrap(); sys.fs_insert(url_to_file_path(&bar_specifier).unwrap(), ""); let sloppy_specifier = root_url.join("api").unwrap(); assert_eq!( sloppy_imports_resolver.resolve( &sloppy_specifier, &referrer, ResolutionKind::Execution ), Some((specifier, SloppyImportsResolutionReason::NoExtension)), ); } } #[test] fn test_sloppy_import_resolution_suggestion_message() { // directory assert_eq!( SloppyImportsResolutionReason::Directory .suggestion_message_for_specifier( &Url::parse("file:///dir/index.js").unwrap() ) .as_str(), "Maybe specify path to 'index.js' file in directory instead" ); // no ext assert_eq!( SloppyImportsResolutionReason::NoExtension .suggestion_message_for_specifier( &Url::parse("file:///dir/index.mjs").unwrap() ) .as_str(), "Maybe add a '.mjs' extension" ); // js to ts assert_eq!( SloppyImportsResolutionReason::JsToTs .suggestion_message_for_specifier( &Url::parse("file:///dir/index.mts").unwrap() ) .as_str(), "Maybe change the extension to '.mts'" ); } #[test] fn resolve_compiler_options_paths_and_sloppy_imports() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "compilerOptions": { "paths": { "@lib": ["lib"], "@lib/*": ["lib/*", "lib/*/mod.ts"], "./src/*": ["./src/*", "./types/*"], "@unmapped/*": [], }, }, }), ); sys.fs_insert(root_dir().join("lib/index.ts"), ""); sys.fs_insert(root_dir().join("lib/foo.ts"), ""); sys.fs_insert(root_dir().join("lib/bar/mod.ts"), ""); sys.fs_insert(root_dir().join("src/baz.ts"), ""); sys.fs_insert(root_dir().join("types/qux.ts"), ""); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys.clone(), super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Enabled, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); let compiler_options_resolver = new_rc(CompilerOptionsResolver::new( &sys, &workspace_dir.workspace, &setup_node_resolver(&sys), &ConfigDiscoveryOption::DiscoverCwd, &Default::default(), )); resolver.set_compiler_options_resolver(compiler_options_resolver); let root_dir_url = workspace_dir.workspace.root_dir_url(); let referrer = root_dir_url.join("main.ts").unwrap(); let resolution = resolver .resolve("@lib", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("lib/index.ts").unwrap().as_str() ); assert_eq!( sloppy_reason, &Some(SloppyImportsResolutionReason::Directory) ); let resolution = resolver .resolve("@lib/foo", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("lib/foo.ts").unwrap().as_str() ); assert_eq!( sloppy_reason, &Some(SloppyImportsResolutionReason::NoExtension) ); let resolution = resolver .resolve("@lib/bar", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("lib/bar/mod.ts").unwrap().as_str() ); assert_eq!(sloppy_reason, &None); let resolution = resolver .resolve("./src/baz.ts", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("src/baz.ts").unwrap().as_str() ); assert_eq!(sloppy_reason, &None); let resolution = resolver .resolve("./src/qux.ts", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("types/qux.ts").unwrap().as_str() ); assert_eq!(sloppy_reason, &None); let resolution = resolver.resolve("@unmapped/foo", &referrer, ResolutionKind::Types); let Err(MappedResolutionError::NotFoundInCompilerOptionsPaths(err)) = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!(err.specifier.as_str(), "@unmapped/foo"); assert_eq!(&err.referrer, &referrer); assert_eq!(&err.matched_key, "@unmapped/*"); } #[test] fn resolve_compiler_options_root_dirs() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["member", "member2"], "compilerOptions": { "rootDirs": ["member", "member2", "member2_types"], }, }), ); // Overrides `rootDirs` from workspace root. sys.fs_insert_json( root_dir().join("member/deno.json"), json!({ "compilerOptions": { "rootDirs": ["foo", "foo_types"], }, }), ); // Use `rootDirs` from workspace root. sys.fs_insert_json(root_dir().join("member2/deno.json"), json!({})); sys.fs_insert(root_dir().join("member/foo_types/import.ts"), ""); sys.fs_insert(root_dir().join("member2_types/import.ts"), ""); // This file should be ignored. It would be used if `member/deno.json` had // no `rootDirs`. sys.fs_insert(root_dir().join("member2_types/foo/import.ts"), ""); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys.clone(), super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); let compiler_options_resolver = new_rc(CompilerOptionsResolver::new( &sys, &workspace_dir.workspace, &setup_node_resolver(&sys), &ConfigDiscoveryOption::DiscoverCwd, &Default::default(), )); resolver.set_compiler_options_resolver(compiler_options_resolver); let root_dir_url = workspace_dir.workspace.root_dir_url(); let referrer = root_dir_url.join("member/foo/mod.ts").unwrap(); let resolution = resolver .resolve("./import.ts", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, used_compiler_options_root_dirs, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url .join("member/foo_types/import.ts") .unwrap() .as_str() ); assert_eq!(sloppy_reason, &None); assert!(used_compiler_options_root_dirs); let referrer = root_dir_url.join("member2/mod.ts").unwrap(); let resolution = resolver .resolve("./import.ts", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, used_compiler_options_root_dirs, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url .join("member2_types/import.ts") .unwrap() .as_str() ); assert_eq!(sloppy_reason, &None); assert!(used_compiler_options_root_dirs); // Ignore rootDirs for `ResolutionKind::Execution`. let referrer = root_dir_url.join("member/foo/mod.ts").unwrap(); let resolution = resolver .resolve("./import.ts", &referrer, ResolutionKind::Execution) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, used_compiler_options_root_dirs, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("member/foo/import.ts").unwrap().as_str() ); assert_eq!(sloppy_reason, &None); assert!(!used_compiler_options_root_dirs); // Ignore rootDirs for `ResolutionKind::Execution`. let referrer = root_dir_url.join("member2/mod.ts").unwrap(); let resolution = resolver .resolve("./import.ts", &referrer, ResolutionKind::Execution) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, used_compiler_options_root_dirs, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url.join("member2/import.ts").unwrap().as_str() ); assert_eq!(sloppy_reason, &None); assert!(!used_compiler_options_root_dirs); } #[test] fn resolve_compiler_options_root_dirs_and_sloppy_imports() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "compilerOptions": { "rootDirs": ["subdir", "subdir_types"], }, }), ); sys.fs_insert(root_dir().join("subdir_types/import.ts"), ""); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys.clone(), super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Enabled, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); let compiler_options_resolver = new_rc(CompilerOptionsResolver::new( &sys, &workspace_dir.workspace, &setup_node_resolver(&sys), &ConfigDiscoveryOption::DiscoverCwd, &Default::default(), )); resolver.set_compiler_options_resolver(compiler_options_resolver); let root_dir_url = workspace_dir.workspace.root_dir_url(); let referrer = root_dir_url.join("subdir/mod.ts").unwrap(); let resolution = resolver .resolve("./import", &referrer, ResolutionKind::Types) .unwrap(); let MappedResolution::Normal { specifier, sloppy_reason, used_compiler_options_root_dirs, .. } = &resolution else { unreachable!("{:#?}", &resolution); }; assert_eq!( specifier.as_str(), root_dir_url .join("subdir_types/import.ts") .unwrap() .as_str() ); assert_eq!( sloppy_reason, &Some(SloppyImportsResolutionReason::NoExtension) ); assert!(used_compiler_options_root_dirs); } #[test] fn specified_import_map() { let sys = InMemorySys::default(); sys.fs_insert_json(root_dir().join("deno.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys, super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: Some(SpecifiedImportMap { base_url: url_from_directory_path(&root_dir()).unwrap(), value: json!({ "imports": { "b": "./b/mod.ts", }, }), }), sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); let root = url_from_directory_path(&root_dir()).unwrap(); match resolver .resolve( "b", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::Normal { specifier, .. } => { assert_eq!(specifier, root.join("b/mod.ts").unwrap()); } _ => unreachable!(), } } #[test] fn specified_import_map_layers_link_scopes() { // A specified import map (e.g. the root's external `importMap` file passed // by the CLI) must still layer in the scopes of linked packages, so a bare // specifier imported from a linked package resolves against that package's // own import map (issue #26764). let sys = InMemorySys::default(); let repo2 = root_dir().join("repo2"); let repo1 = root_dir().join("repo1"); sys.fs_insert_json( repo2.join("deno.json"), json!({ "importMap": "./import_map.json", }), ); sys.fs_insert_json( repo2.join("import_map.json"), json!({ "imports": { "@jpravetz/bmod": "../repo1/bmod/mod.ts", }, }), ); sys.fs_insert_json( repo1.join("bmod/deno.json"), json!({ "imports": { "@scope/amod": "../amod/mod.ts", }, }), ); let workspace_dir = workspace_at_start_dir(&sys, &repo2); let resolver = WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys, super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, // mimic the CLI loading the root's external `importMap` file specified_import_map: Some(SpecifiedImportMap { base_url: url_from_file_path(&repo2.join("import_map.json")).unwrap(), value: json!({ "imports": { "@jpravetz/bmod": "../repo1/bmod/mod.ts", }, }), }), sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); let referrer = url_from_file_path(&repo1.join("bmod/mod.ts")).unwrap(); let MappedResolution::Normal { specifier, .. } = resolver .resolve("@scope/amod", &referrer, ResolutionKind::Execution) .unwrap() else { unreachable!(); }; assert_eq!( specifier.as_str(), url_from_file_path(&repo1.join("amod/mod.ts")) .unwrap() .as_str() ); } #[test] fn workspace_specified_import_map() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./a"] }), ); sys.fs_insert_json(root_dir().join("a").join("deno.json"), json!({})); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); WorkspaceResolver::from_workspace( &workspace_dir.workspace, UnreachableSys, super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: Some(SpecifiedImportMap { base_url: url_from_directory_path(&root_dir()).unwrap(), value: json!({ "imports": { "b": "./b/mod.ts", }, }), }), sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap(); } #[test] fn resolves_link_member_with_version() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "links": ["../link"] }), ); sys.fs_insert_json( root_dir().join("../link/deno.json"), json!({ "name": "@scope/link", "version": "1.0.0", "exports": "./mod.ts" }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace_dir); let root = url_from_directory_path(&root_dir()).unwrap(); // Linked packages resolve via bare specifier, just like workspace members. match resolver .resolve( "@scope/link", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("../link/mod.ts").unwrap()); } _ => unreachable!(), } // matching version match resolver .resolve( "jsr:@scope/link@1", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("../link/mod.ts").unwrap()); } _ => unreachable!(), } // not matching version match resolver .resolve( "jsr:@scope/link@2", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::Normal { specifier, maybe_diagnostic, .. } => { assert_eq!(specifier, Url::parse("jsr:@scope/link@2").unwrap()); assert_eq!( maybe_diagnostic, Some(Box::new( MappedResolutionDiagnostic::ConstraintNotMatchedLocalVersion { is_link: true, reference: JsrPackageReqReference::from_str("jsr:@scope/link@2") .unwrap(), local_version: Version::parse_from_npm("1.0.0").unwrap(), } )) ); } _ => unreachable!(), } } #[test] fn resolves_link_member_no_version() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "links": ["../link"] }), ); sys.fs_insert_json( root_dir().join("../link/deno.json"), json!({ "name": "@scope/link", "exports": "./mod.ts" }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace_dir); let root = url_from_directory_path(&root_dir()).unwrap(); // Linked packages resolve via bare specifier, just like workspace members. match resolver .resolve( "@scope/link", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("../link/mod.ts").unwrap()); } _ => unreachable!(), } // always resolves, no matter what version match resolver .resolve( "jsr:@scope/link@12", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("../link/mod.ts").unwrap()); } _ => unreachable!(), } } #[test] fn resolves_workspace_member() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("./member/deno.json"), json!({ "name": "@scope/member", "version": "1.0.0", "exports": "./mod.ts" }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace_dir); let root = url_from_directory_path(&root_dir()).unwrap(); match resolver .resolve( "@scope/member", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("./member/mod.ts").unwrap()); } _ => unreachable!(), } // matching version match resolver .resolve( "jsr:@scope/member@1", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("./member/mod.ts").unwrap()); } _ => unreachable!(), } // not matching version match resolver .resolve( "jsr:@scope/member@2", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::Normal { specifier, maybe_diagnostic, .. } => { assert_eq!(specifier, Url::parse("jsr:@scope/member@2").unwrap()); assert_eq!( maybe_diagnostic, Some(Box::new( MappedResolutionDiagnostic::ConstraintNotMatchedLocalVersion { is_link: false, reference: JsrPackageReqReference::from_str( "jsr:@scope/member@2" ) .unwrap(), local_version: Version::parse_from_npm("1.0.0").unwrap(), } )) ); } _ => unreachable!(), } } #[test] fn resolves_link_workspace() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "imports": { "@std/fs": "jsr:@std/fs@0.200.0" }, "links": ["../link"] }), ); sys.fs_insert_json( root_dir().join("../link/deno.json"), json!({ "workspace": ["./member"] }), ); sys.fs_insert_json( root_dir().join("../link/member/deno.json"), json!({ "name": "@scope/link", "version": "1.0.0", "exports": "./mod.ts", "imports": { "@std/fs": "jsr:@std/fs@1" } }), ); let workspace_dir = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace_dir); let root = url_from_directory_path(&root_dir()).unwrap(); match resolver .resolve( "jsr:@scope/link@1", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::WorkspaceJsrPackage { specifier, .. } => { assert_eq!(specifier, root.join("../link/member/mod.ts").unwrap()); } _ => unreachable!(), } // resolving @std/fs from root match resolver .resolve( "@std/fs", &root.join("main.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::Normal { specifier, .. } => { assert_eq!(specifier, Url::parse("jsr:@std/fs@0.200.0").unwrap()); } _ => unreachable!(), } // resolving @std/fs in linked package match resolver .resolve( "@std/fs", &root.join("../link/member/mod.ts").unwrap(), ResolutionKind::Execution, ) .unwrap() { MappedResolution::Normal { specifier, .. } => { assert_eq!(specifier, Url::parse("jsr:@std/fs@1").unwrap()); } _ => unreachable!(), } } #[test] fn invalid_package_name_with_slashes() { let sys = InMemorySys::default(); sys.fs_insert_json( root_dir().join("deno.json"), json!({ "workspace": ["./libs/math"] }), ); sys.fs_insert_json( root_dir().join("libs/math/deno.json"), json!({ "name": "@deno-test/libs/math", // Invalid package name containing slashes "version": "1.0.0", "exports": "./mod.ts" }), ); let workspace = workspace_at_start_dir(&sys, &root_dir()); let resolver = create_resolver(&workspace); let result = resolver.resolve( "@deno-test/libs/math", &url_from_file_path(&root_dir().join("main.ts")).unwrap(), ResolutionKind::Execution, ); // Resolve shouldn't panic and tt should result in unmapped // bare specifier error as the package name is invalid. assert!(result.err().unwrap().is_unmapped_bare_specifier()); let diagnostics = workspace.workspace.diagnostics(); assert_eq!(diagnostics.len(), 1); assert!( diagnostics.first().unwrap().to_string().starts_with( r#"Invalid workspace member name "@deno-test/libs/math"."# ) ); } fn create_resolver( workspace_dir: &WorkspaceDirectory, ) -> WorkspaceResolver<UnreachableSys> { WorkspaceResolver::from_workspace( &workspace_dir.workspace, UnreachableSys, super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap() } // Like `create_resolver`, but uses a real (in-memory) sys so the resolver can // read external `importMap` files referenced by config files. fn create_resolver_with_sys( sys: InMemorySys, workspace_dir: &WorkspaceDirectory, ) -> WorkspaceResolver<InMemorySys> { WorkspaceResolver::from_workspace( &workspace_dir.workspace, sys, super::CreateResolverOptions { pkg_json_dep_resolution: PackageJsonDepResolution::Enabled, specified_import_map: None, sloppy_imports_options: SloppyImportsOptions::Unspecified, fs_cache_options: FsCacheOptions::Enabled, }, ) .unwrap() } fn workspace_at_start_dir( sys: &InMemorySys, start_dir: &Path, ) -> WorkspaceDirectoryRc { WorkspaceDirectory::discover( sys, WorkspaceDiscoverStart::Paths(&[start_dir.to_path_buf()]), &WorkspaceDiscoverOptions { discover_pkg_json: true, ..Default::default() }, ) .unwrap() } #[test] fn test_pkg_json_to_version_info() { fn convert( text: &str, ) -> Result<NpmPackageVersionInfo, PkgJsonToVersionInfoError> { let pkg_json = deno_package_json::PackageJson::load_from_string( PathBuf::from("package.json"), text, ) .unwrap(); pkg_json_to_version_info(&pkg_json) } assert_eq!( convert( r#"{ "name": "pkg", "version": "1.0.0", "bin": "./bin.js", "dependencies": { "my-dep": "1" }, "optionalDependencies": { "optional-dep": "~1" }, "peerDependencies": { "my-peer-dep": "^2" }, "bundleDependencies": [ "my-dep" ], "peerDependenciesMeta": { "my-peer-dep": { "optional": true } }, "os": ["win32"], "cpu": ["x86_64"], "scripts": { "script": "testing", "postInstall": "testing2" }, "deprecated": "ignored for now" }"# ) .unwrap(), NpmPackageVersionInfo { version: Version::parse_from_npm("1.0.0").unwrap(), exports: None, dist: None, has_install_script: None, bin: Some(deno_npm::registry::NpmPackageVersionBinEntry::String( "./bin.js".to_string() )), dependencies: HashMap::from([( StackString::from_static("my-dep"), StackString::from_static("1") )]), bundle_dependencies: Vec::from([StackString::from_static("my-dep")]), bundled_dependencies: Vec::new(), optional_dependencies: HashMap::from([( StackString::from_static("optional-dep"), StackString::from_static("~1") )]), peer_dependencies: HashMap::from([( StackString::from_static("my-peer-dep"), StackString::from_static("^2") )]), peer_dependencies_meta: HashMap::from([( StackString::from_static("my-peer-dep"), NpmPeerDependencyMeta { optional: true } )]), os: vec![SmallStackString::from_static("win32")], cpu: vec![SmallStackString::from_static("x86_64")], scripts: HashMap::from([ ( SmallStackString::from_static("script"), "testing".to_string(), ), ( SmallStackString::from_static("postInstall"), "testing2".to_string(), ) ]), // we don't bother ever setting this because we don't store it in deno_package_json deprecated: None, npm_user: None, } ); match convert("{}").unwrap_err() { PkgJsonToVersionInfoError::VersionMissing => { // ok } _ => unreachable!(), } match convert(r#"{ "version": "1.0.~" }"#).unwrap_err() { PkgJsonToVersionInfoError::VersionInvalid { source: err } => { assert_eq!(err.to_string(), "Invalid npm version"); } _ => unreachable!(), } } }