1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-11-26 16:09:27 -05:00
denoland-deno/cli/resolver.rs
Divy Srivastava ed65bc6abc
refactor(cli): decouple resolvers from module_loader.rs for standalone use (#22147)
It makes it easier to write a standalone bin target for `deno compile`
without pulling a lot of the tooling and tsc loader logic
2024-01-27 09:10:36 -08:00

1226 lines
37 KiB
Rust

// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license.
use deno_ast::MediaType;
use deno_core::anyhow::anyhow;
use deno_core::anyhow::Context;
use deno_core::error::generic_error;
use deno_core::error::AnyError;
use deno_core::futures::future;
use deno_core::futures::future::LocalBoxFuture;
use deno_core::futures::FutureExt;
use deno_core::parking_lot::Mutex;
use deno_core::ModuleCodeString;
use deno_core::ModuleSpecifier;
use deno_graph::source::NpmPackageReqResolution;
use deno_graph::source::NpmResolver;
use deno_graph::source::ResolutionMode;
use deno_graph::source::ResolveError;
use deno_graph::source::Resolver;
use deno_graph::source::UnknownBuiltInNodeModuleError;
use deno_graph::source::DEFAULT_JSX_IMPORT_SOURCE_MODULE;
use deno_runtime::deno_fs;
use deno_runtime::deno_fs::FileSystem;
use deno_runtime::deno_node::is_builtin_node_module;
use deno_runtime::deno_node::parse_npm_pkg_name;
use deno_runtime::deno_node::NodeResolution;
use deno_runtime::deno_node::NodeResolutionMode;
use deno_runtime::deno_node::NodeResolver;
use deno_runtime::deno_node::NpmResolver as DenoNodeNpmResolver;
use deno_runtime::permissions::PermissionsContainer;
use deno_semver::npm::NpmPackageReqReference;
use deno_semver::package::PackageReq;
use import_map::ImportMap;
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::args::package_json::PackageJsonDeps;
use crate::args::JsxImportSourceConfig;
use crate::args::PackageJsonDepsProvider;
use crate::graph_util::format_range_with_colors;
use crate::node::CliNodeCodeTranslator;
use crate::npm::ByonmCliNpmResolver;
use crate::npm::CliNpmResolver;
use crate::npm::InnerCliNpmResolverRef;
use crate::util::path::specifier_to_file_path;
use crate::util::sync::AtomicFlag;
pub struct ModuleCodeStringSource {
pub code: ModuleCodeString,
pub found_url: ModuleSpecifier,
pub media_type: MediaType,
}
pub struct CliNodeResolver {
cjs_resolutions: Arc<CjsResolutionStore>,
node_resolver: Arc<NodeResolver>,
pub(crate) npm_resolver: Arc<dyn CliNpmResolver>,
}
impl CliNodeResolver {
pub fn new(
cjs_resolutions: Arc<CjsResolutionStore>,
node_resolver: Arc<NodeResolver>,
npm_resolver: Arc<dyn CliNpmResolver>,
) -> Self {
Self {
cjs_resolutions,
node_resolver,
npm_resolver,
}
}
pub fn in_npm_package(&self, referrer: &ModuleSpecifier) -> bool {
self.npm_resolver.in_npm_package(referrer)
}
pub fn resolve_if_in_npm_package(
&self,
specifier: &str,
referrer: &ModuleSpecifier,
permissions: &PermissionsContainer,
) -> Option<Result<ModuleSpecifier, AnyError>> {
if self.in_npm_package(referrer) {
// we're in an npm package, so use node resolution
Some(
self
.handle_node_resolve_result(self.node_resolver.resolve(
specifier,
referrer,
NodeResolutionMode::Execution,
permissions,
))
.with_context(|| {
format!("Could not resolve '{specifier}' from '{referrer}'.")
}),
)
} else {
None
}
}
pub fn resolve_req_reference(
&self,
req_ref: &NpmPackageReqReference,
permissions: &PermissionsContainer,
referrer: &ModuleSpecifier,
) -> Result<ModuleSpecifier, AnyError> {
let package_folder = self
.npm_resolver
.resolve_pkg_folder_from_deno_module_req(req_ref.req(), referrer)?;
self
.resolve_package_sub_path(
&package_folder,
req_ref.sub_path(),
referrer,
permissions,
)
.with_context(|| format!("Could not resolve '{}'.", req_ref))
}
pub fn resolve_package_sub_path(
&self,
package_folder: &Path,
sub_path: Option<&str>,
referrer: &ModuleSpecifier,
permissions: &PermissionsContainer,
) -> Result<ModuleSpecifier, AnyError> {
self.handle_node_resolve_result(
self.node_resolver.resolve_package_subpath_from_deno_module(
package_folder,
sub_path,
referrer,
NodeResolutionMode::Execution,
permissions,
),
)
}
fn handle_node_resolve_result(
&self,
result: Result<Option<NodeResolution>, AnyError>,
) -> Result<ModuleSpecifier, AnyError> {
let response = match result? {
Some(response) => response,
None => return Err(generic_error("not found")),
};
if let NodeResolution::CommonJs(specifier) = &response {
// remember that this was a common js resolution
self.cjs_resolutions.insert(specifier.clone());
}
Ok(response.into_url())
}
}
pub struct NpmModuleLoader {
cjs_resolutions: Arc<CjsResolutionStore>,
node_code_translator: Arc<CliNodeCodeTranslator>,
fs: Arc<dyn deno_fs::FileSystem>,
node_resolver: Arc<CliNodeResolver>,
}
impl NpmModuleLoader {
pub fn new(
cjs_resolutions: Arc<CjsResolutionStore>,
node_code_translator: Arc<CliNodeCodeTranslator>,
fs: Arc<dyn deno_fs::FileSystem>,
node_resolver: Arc<CliNodeResolver>,
) -> Self {
Self {
cjs_resolutions,
node_code_translator,
fs,
node_resolver,
}
}
pub fn maybe_prepare_load(
&self,
specifier: &ModuleSpecifier,
) -> Option<Result<(), AnyError>> {
if self.node_resolver.in_npm_package(specifier) {
// nothing to prepare
Some(Ok(()))
} else {
None
}
}
pub fn load_sync_if_in_npm_package(
&self,
specifier: &ModuleSpecifier,
maybe_referrer: Option<&ModuleSpecifier>,
permissions: &PermissionsContainer,
) -> Option<Result<ModuleCodeStringSource, AnyError>> {
if self.node_resolver.in_npm_package(specifier) {
Some(self.load_sync(specifier, maybe_referrer, permissions))
} else {
None
}
}
fn load_sync(
&self,
specifier: &ModuleSpecifier,
maybe_referrer: Option<&ModuleSpecifier>,
permissions: &PermissionsContainer,
) -> Result<ModuleCodeStringSource, AnyError> {
let file_path = specifier.to_file_path().unwrap();
let code = self
.fs
.read_text_file_sync(&file_path)
.map_err(AnyError::from)
.with_context(|| {
if file_path.is_dir() {
// directory imports are not allowed when importing from an
// ES module, so provide the user with a helpful error message
let dir_path = file_path;
let mut msg = "Directory import ".to_string();
msg.push_str(&dir_path.to_string_lossy());
if let Some(referrer) = &maybe_referrer {
msg.push_str(" is not supported resolving import from ");
msg.push_str(referrer.as_str());
let entrypoint_name = ["index.mjs", "index.js", "index.cjs"]
.iter()
.find(|e| dir_path.join(e).is_file());
if let Some(entrypoint_name) = entrypoint_name {
msg.push_str("\nDid you mean to import ");
msg.push_str(entrypoint_name);
msg.push_str(" within the directory?");
}
}
msg
} else {
let mut msg = "Unable to load ".to_string();
msg.push_str(&file_path.to_string_lossy());
if let Some(referrer) = &maybe_referrer {
msg.push_str(" imported from ");
msg.push_str(referrer.as_str());
}
msg
}
})?;
let code = if self.cjs_resolutions.contains(specifier) {
// translate cjs to esm if it's cjs and inject node globals
self.node_code_translator.translate_cjs_to_esm(
specifier,
Some(code.as_str()),
permissions,
)?
} else {
// esm and json code is untouched
code
};
Ok(ModuleCodeStringSource {
code: code.into(),
found_url: specifier.clone(),
media_type: MediaType::from_specifier(specifier),
})
}
}
/// Keeps track of what module specifiers were resolved as CJS.
#[derive(Debug, Default)]
pub struct CjsResolutionStore(Mutex<HashSet<ModuleSpecifier>>);
impl CjsResolutionStore {
pub fn contains(&self, specifier: &ModuleSpecifier) -> bool {
self.0.lock().contains(specifier)
}
pub fn insert(&self, specifier: ModuleSpecifier) {
self.0.lock().insert(specifier);
}
}
/// Result of checking if a specifier is mapped via
/// an import map or package.json.
pub enum MappedResolution {
None,
PackageJson(ModuleSpecifier),
ImportMap(ModuleSpecifier),
}
impl MappedResolution {
pub fn into_specifier(self) -> Option<ModuleSpecifier> {
match self {
MappedResolution::None => Option::None,
MappedResolution::PackageJson(specifier) => Some(specifier),
MappedResolution::ImportMap(specifier) => Some(specifier),
}
}
}
/// Resolver for specifiers that could be mapped via an
/// import map or package.json.
#[derive(Debug)]
pub struct MappedSpecifierResolver {
maybe_import_map: Option<Arc<ImportMap>>,
package_json_deps_provider: Arc<PackageJsonDepsProvider>,
}
impl MappedSpecifierResolver {
pub fn new(
maybe_import_map: Option<Arc<ImportMap>>,
package_json_deps_provider: Arc<PackageJsonDepsProvider>,
) -> Self {
Self {
maybe_import_map,
package_json_deps_provider,
}
}
pub fn resolve(
&self,
specifier: &str,
referrer: &ModuleSpecifier,
) -> Result<MappedResolution, AnyError> {
// attempt to resolve with the import map first
let maybe_import_map_err = match self
.maybe_import_map
.as_ref()
.map(|import_map| import_map.resolve(specifier, referrer))
{
Some(Ok(value)) => return Ok(MappedResolution::ImportMap(value)),
Some(Err(err)) => Some(err),
None => None,
};
// then with package.json
if let Some(deps) = self.package_json_deps_provider.deps() {
if let Some(specifier) = resolve_package_json_dep(specifier, deps)? {
return Ok(MappedResolution::PackageJson(specifier));
}
}
// otherwise, surface the import map error or try resolving when has no import map
if let Some(err) = maybe_import_map_err {
Err(err.into())
} else {
Ok(MappedResolution::None)
}
}
}
/// A resolver that takes care of resolution, taking into account loaded
/// import map, JSX settings.
#[derive(Debug)]
pub struct CliGraphResolver {
fs: Arc<dyn FileSystem>,
sloppy_imports_resolver: Option<SloppyImportsResolver>,
mapped_specifier_resolver: MappedSpecifierResolver,
maybe_default_jsx_import_source: Option<String>,
maybe_jsx_import_source_module: Option<String>,
maybe_vendor_specifier: Option<ModuleSpecifier>,
cjs_resolutions: Option<Arc<CjsResolutionStore>>,
node_resolver: Option<Arc<NodeResolver>>,
npm_resolver: Option<Arc<dyn CliNpmResolver>>,
found_package_json_dep_flag: Arc<AtomicFlag>,
bare_node_builtins_enabled: bool,
}
pub struct CliGraphResolverOptions<'a> {
pub fs: Arc<dyn FileSystem>,
pub cjs_resolutions: Option<Arc<CjsResolutionStore>>,
pub sloppy_imports_resolver: Option<SloppyImportsResolver>,
pub node_resolver: Option<Arc<NodeResolver>>,
pub npm_resolver: Option<Arc<dyn CliNpmResolver>>,
pub package_json_deps_provider: Arc<PackageJsonDepsProvider>,
pub maybe_jsx_import_source_config: Option<JsxImportSourceConfig>,
pub maybe_import_map: Option<Arc<ImportMap>>,
pub maybe_vendor_dir: Option<&'a PathBuf>,
pub bare_node_builtins_enabled: bool,
}
impl CliGraphResolver {
pub fn new(options: CliGraphResolverOptions) -> Self {
let is_byonm = options
.npm_resolver
.as_ref()
.map(|n| n.as_byonm().is_some())
.unwrap_or(false);
Self {
fs: options.fs,
cjs_resolutions: options.cjs_resolutions,
sloppy_imports_resolver: options.sloppy_imports_resolver,
mapped_specifier_resolver: MappedSpecifierResolver::new(
options.maybe_import_map,
if is_byonm {
// don't resolve from the root package.json deps for byonm
Arc::new(PackageJsonDepsProvider::new(None))
} else {
options.package_json_deps_provider
},
),
maybe_default_jsx_import_source: options
.maybe_jsx_import_source_config
.as_ref()
.and_then(|c| c.default_specifier.clone()),
maybe_jsx_import_source_module: options
.maybe_jsx_import_source_config
.map(|c| c.module),
maybe_vendor_specifier: options
.maybe_vendor_dir
.and_then(|v| ModuleSpecifier::from_directory_path(v).ok()),
node_resolver: options.node_resolver,
npm_resolver: options.npm_resolver,
found_package_json_dep_flag: Default::default(),
bare_node_builtins_enabled: options.bare_node_builtins_enabled,
}
}
pub fn as_graph_resolver(&self) -> &dyn Resolver {
self
}
pub fn as_graph_npm_resolver(&self) -> &dyn NpmResolver {
self
}
pub fn found_package_json_dep(&self) -> bool {
self.found_package_json_dep_flag.is_raised()
}
fn check_surface_byonm_node_error(
&self,
specifier: &str,
referrer: &ModuleSpecifier,
mode: NodeResolutionMode,
original_err: AnyError,
resolver: &ByonmCliNpmResolver,
) -> Result<(), AnyError> {
if let Ok((pkg_name, _, _)) = parse_npm_pkg_name(specifier, referrer) {
match resolver
.resolve_package_folder_from_package(&pkg_name, referrer, mode)
{
Ok(_) => {
return Err(original_err);
}
Err(_) => {
if resolver
.find_ancestor_package_json_with_dep(&pkg_name, referrer)
.is_some()
{
return Err(anyhow!(
concat!(
"Could not resolve \"{}\", but found it in a package.json. ",
"Deno expects the node_modules/ directory to be up to date. ",
"Did you forget to run `npm install`?"
),
specifier
));
}
}
}
}
Ok(())
}
}
impl Resolver for CliGraphResolver {
fn default_jsx_import_source(&self) -> Option<String> {
self.maybe_default_jsx_import_source.clone()
}
fn jsx_import_source_module(&self) -> &str {
self
.maybe_jsx_import_source_module
.as_deref()
.unwrap_or(DEFAULT_JSX_IMPORT_SOURCE_MODULE)
}
fn resolve(
&self,
specifier: &str,
referrer_range: &deno_graph::Range,
mode: ResolutionMode,
) -> Result<ModuleSpecifier, ResolveError> {
fn to_node_mode(mode: ResolutionMode) -> NodeResolutionMode {
match mode {
ResolutionMode::Execution => NodeResolutionMode::Execution,
ResolutionMode::Types => NodeResolutionMode::Types,
}
}
let referrer = &referrer_range.specifier;
let result = self
.mapped_specifier_resolver
.resolve(specifier, referrer)
.map_err(|err| err.into())
.and_then(|resolution| match resolution {
MappedResolution::ImportMap(specifier) => Ok(specifier),
MappedResolution::PackageJson(specifier) => {
// found a specifier in the package.json, so mark that
// we need to do an "npm install" later
self.found_package_json_dep_flag.raise();
Ok(specifier)
}
MappedResolution::None => {
deno_graph::resolve_import(specifier, &referrer_range.specifier)
.map_err(|err| err.into())
}
});
// do sloppy imports resolution if enabled
let result =
if let Some(sloppy_imports_resolver) = &self.sloppy_imports_resolver {
result.map(|specifier| {
sloppy_imports_resolve(
sloppy_imports_resolver,
specifier,
referrer_range,
)
})
} else {
result
};
// When the user is vendoring, don't allow them to import directly from the vendor/ directory
// as it might cause them confusion or duplicate dependencies. Additionally, this folder has
// special treatment in the language server so it will definitely cause issues/confusion there
// if they do this.
if let Some(vendor_specifier) = &self.maybe_vendor_specifier {
if let Ok(specifier) = &result {
if specifier.as_str().starts_with(vendor_specifier.as_str()) {
return Err(ResolveError::Other(anyhow!("Importing from the vendor directory is not permitted. Use a remote specifier instead or disable vendoring.")));
}
}
}
if let Some(resolver) =
self.npm_resolver.as_ref().and_then(|r| r.as_byonm())
{
match &result {
Ok(specifier) => {
if let Ok(npm_req_ref) =
NpmPackageReqReference::from_specifier(specifier)
{
let package_folder = resolver
.resolve_pkg_folder_from_deno_module_req(
npm_req_ref.req(),
referrer,
)?;
let node_resolver = self.node_resolver.as_ref().unwrap();
let package_json_path = package_folder.join("package.json");
if !self.fs.exists_sync(&package_json_path) {
return Err(ResolveError::Other(anyhow!(
"Could not find '{}'. Deno expects the node_modules/ directory to be up to date. Did you forget to run `npm install`?",
package_json_path.display()
)));
}
let maybe_resolution = node_resolver
.resolve_package_subpath_from_deno_module(
&package_folder,
npm_req_ref.sub_path(),
referrer,
to_node_mode(mode),
&PermissionsContainer::allow_all(),
)?;
match maybe_resolution {
Some(resolution) => {
if let Some(cjs_resolutions) = &self.cjs_resolutions {
if let NodeResolution::CommonJs(specifier) = &resolution {
// remember that this was a common js resolution
cjs_resolutions.insert(specifier.clone());
}
}
return Ok(resolution.into_url());
}
None => {
return Err(ResolveError::Other(anyhow!(
"Failed resolving package subpath for '{}' in '{}'.",
npm_req_ref,
package_folder.display()
)));
}
}
}
}
Err(_) => {
if referrer.scheme() == "file" {
if let Some(node_resolver) = &self.node_resolver {
let node_result = node_resolver.resolve(
specifier,
referrer,
to_node_mode(mode),
&PermissionsContainer::allow_all(),
);
match node_result {
Ok(Some(resolution)) => {
if let Some(cjs_resolutions) = &self.cjs_resolutions {
if let NodeResolution::CommonJs(specifier) = &resolution {
// remember that this was a common js resolution
cjs_resolutions.insert(specifier.clone());
}
}
return Ok(resolution.into_url());
}
Ok(None) => {
self
.check_surface_byonm_node_error(
specifier,
referrer,
to_node_mode(mode),
anyhow!("Cannot find \"{}\"", specifier),
resolver,
)
.map_err(ResolveError::Other)?;
}
Err(err) => {
self
.check_surface_byonm_node_error(
specifier,
referrer,
to_node_mode(mode),
err,
resolver,
)
.map_err(ResolveError::Other)?;
}
}
}
}
}
}
}
result
}
}
fn sloppy_imports_resolve(
resolver: &SloppyImportsResolver,
specifier: ModuleSpecifier,
referrer_range: &deno_graph::Range,
) -> ModuleSpecifier {
let resolution = resolver.resolve(&specifier);
let hint_message = match &resolution {
SloppyImportsResolution::JsToTs(to_specifier) => {
let from_media_type = MediaType::from_specifier(&specifier);
let to_media_type = MediaType::from_specifier(to_specifier);
format!(
"update {} extension to {}",
from_media_type.as_ts_extension(),
to_media_type.as_ts_extension()
)
}
SloppyImportsResolution::NoExtension(to_specifier) => {
let to_media_type = MediaType::from_specifier(to_specifier);
format!("add {} extension", to_media_type.as_ts_extension())
}
SloppyImportsResolution::Directory(to_specifier) => {
let file_name = to_specifier
.path()
.rsplit_once('/')
.map(|(_, file_name)| file_name)
.unwrap_or(to_specifier.path());
format!("specify path to {} file in directory instead", file_name)
}
SloppyImportsResolution::None(_) => return specifier,
};
// show a warning when this happens in order to drive
// the user towards correcting these specifiers
log::warn!(
"{} Sloppy module resolution {}\n at {}",
crate::colors::yellow("Warning"),
crate::colors::gray(format!("(hint: {})", hint_message)),
if referrer_range.end == deno_graph::Position::zeroed() {
// not worth showing the range in this case
crate::colors::cyan(referrer_range.specifier.as_str()).to_string()
} else {
format_range_with_colors(referrer_range)
},
);
resolution.into_specifier().into_owned()
}
fn resolve_package_json_dep(
specifier: &str,
deps: &PackageJsonDeps,
) -> Result<Option<ModuleSpecifier>, AnyError> {
for (bare_specifier, req_result) in deps {
if specifier.starts_with(bare_specifier) {
let path = &specifier[bare_specifier.len()..];
if path.is_empty() || path.starts_with('/') {
let req = req_result.as_ref().map_err(|err| {
anyhow!(
"Parsing version constraints in the application-level package.json is more strict at the moment.\n\n{:#}",
err.clone()
)
})?;
return Ok(Some(ModuleSpecifier::parse(&format!("npm:{req}{path}"))?));
}
}
}
Ok(None)
}
impl NpmResolver for CliGraphResolver {
fn resolve_builtin_node_module(
&self,
specifier: &ModuleSpecifier,
) -> Result<Option<String>, UnknownBuiltInNodeModuleError> {
if specifier.scheme() != "node" {
return Ok(None);
}
let module_name = specifier.path().to_string();
if is_builtin_node_module(&module_name) {
Ok(Some(module_name))
} else {
Err(UnknownBuiltInNodeModuleError { module_name })
}
}
fn on_resolve_bare_builtin_node_module(
&self,
module_name: &str,
range: &deno_graph::Range,
) {
let deno_graph::Range {
start, specifier, ..
} = range;
let line = start.line + 1;
let column = start.character + 1;
log::warn!("Warning: Resolving \"{module_name}\" as \"node:{module_name}\" at {specifier}:{line}:{column}. If you want to use a built-in Node module, add a \"node:\" prefix.")
}
fn load_and_cache_npm_package_info(
&self,
package_name: &str,
) -> LocalBoxFuture<'static, Result<(), AnyError>> {
match &self.npm_resolver {
Some(npm_resolver) if npm_resolver.as_managed().is_some() => {
let package_name = package_name.to_string();
let npm_resolver = npm_resolver.clone();
async move {
if let Some(managed) = npm_resolver.as_managed() {
managed.cache_package_info(&package_name).await?;
}
Ok(())
}
.boxed()
}
_ => {
// return it succeeded and error at the import site below
Box::pin(future::ready(Ok(())))
}
}
}
fn resolve_npm(&self, package_req: &PackageReq) -> NpmPackageReqResolution {
match &self.npm_resolver {
Some(npm_resolver) => match npm_resolver.as_inner() {
InnerCliNpmResolverRef::Managed(npm_resolver) => {
npm_resolver.resolve_npm_for_deno_graph(package_req)
}
// if we are using byonm, then this should never be called because
// we don't use deno_graph's npm resolution in this case
InnerCliNpmResolverRef::Byonm(_) => unreachable!(),
},
None => NpmPackageReqResolution::Err(anyhow!(
"npm specifiers were requested; but --no-npm is specified"
)),
}
}
fn enables_bare_builtin_node_module(&self) -> bool {
self.bare_node_builtins_enabled
}
}
#[derive(Debug)]
struct SloppyImportsStatCache {
fs: Arc<dyn FileSystem>,
cache: Mutex<HashMap<PathBuf, Option<SloppyImportsFsEntry>>>,
}
impl SloppyImportsStatCache {
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self {
fs,
cache: Default::default(),
}
}
pub fn stat_sync(&self, path: &Path) -> Option<SloppyImportsFsEntry> {
// there will only ever be one thread in here at a
// time, so it's ok to hold the lock for so long
let mut cache = self.cache.lock();
if let Some(entry) = cache.get(path) {
return *entry;
}
let entry = self
.fs
.stat_sync(path)
.ok()
.and_then(|stat| SloppyImportsFsEntry::from_fs_stat(&stat));
cache.insert(path.to_owned(), entry);
entry
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SloppyImportsFsEntry {
File,
Dir,
}
impl SloppyImportsFsEntry {
pub fn from_fs_stat(
stat: &deno_runtime::deno_io::fs::FsStat,
) -> Option<SloppyImportsFsEntry> {
if stat.is_file {
Some(SloppyImportsFsEntry::File)
} else if stat.is_directory {
Some(SloppyImportsFsEntry::Dir)
} else {
None
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum SloppyImportsResolution<'a> {
/// No sloppy resolution was found.
None(&'a ModuleSpecifier),
/// Ex. `./file.js` to `./file.ts`
JsToTs(ModuleSpecifier),
/// Ex. `./file` to `./file.ts`
NoExtension(ModuleSpecifier),
/// Ex. `./dir` to `./dir/index.ts`
Directory(ModuleSpecifier),
}
impl<'a> SloppyImportsResolution<'a> {
pub fn as_specifier(&self) -> &ModuleSpecifier {
match self {
Self::None(specifier) => specifier,
Self::JsToTs(specifier) => specifier,
Self::NoExtension(specifier) => specifier,
Self::Directory(specifier) => specifier,
}
}
pub fn into_specifier(self) -> Cow<'a, ModuleSpecifier> {
match self {
Self::None(specifier) => Cow::Borrowed(specifier),
Self::JsToTs(specifier) => Cow::Owned(specifier),
Self::NoExtension(specifier) => Cow::Owned(specifier),
Self::Directory(specifier) => Cow::Owned(specifier),
}
}
pub fn as_suggestion_message(&self) -> Option<String> {
Some(format!("Maybe {}", self.as_base_message()?))
}
pub fn as_lsp_quick_fix_message(&self) -> Option<String> {
let message = self.as_base_message()?;
let mut chars = message.chars();
Some(format!(
"{}{}.",
chars.next().unwrap().to_uppercase(),
chars.as_str()
))
}
fn as_base_message(&self) -> Option<String> {
match self {
SloppyImportsResolution::None(_) => None,
SloppyImportsResolution::JsToTs(specifier) => {
let media_type = MediaType::from_specifier(specifier);
Some(format!(
"change the extension to '{}'",
media_type.as_ts_extension()
))
}
SloppyImportsResolution::NoExtension(specifier) => {
let media_type = MediaType::from_specifier(specifier);
Some(format!(
"add a '{}' extension",
media_type.as_ts_extension()
))
}
SloppyImportsResolution::Directory(specifier) => {
let file_name = specifier
.path()
.rsplit_once('/')
.map(|(_, file_name)| file_name)
.unwrap_or(specifier.path());
Some(format!(
"specify path to '{}' file in directory instead",
file_name
))
}
}
}
}
#[derive(Debug)]
pub struct SloppyImportsResolver {
stat_cache: SloppyImportsStatCache,
}
impl SloppyImportsResolver {
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
Self {
stat_cache: SloppyImportsStatCache::new(fs),
}
}
pub fn resolve_with_fs<'a>(
fs: &dyn FileSystem,
specifier: &'a ModuleSpecifier,
) -> SloppyImportsResolution<'a> {
Self::resolve_with_stat_sync(specifier, |path| {
fs.stat_sync(path)
.ok()
.and_then(|stat| SloppyImportsFsEntry::from_fs_stat(&stat))
})
}
pub fn resolve_with_stat_sync(
specifier: &ModuleSpecifier,
stat_sync: impl Fn(&Path) -> Option<SloppyImportsFsEntry>,
) -> SloppyImportsResolution {
if specifier.scheme() != "file" {
return SloppyImportsResolution::None(specifier);
}
let Ok(path) = specifier_to_file_path(specifier) else {
return SloppyImportsResolution::None(specifier);
};
let mut is_dir_resolution = false;
let mut is_no_ext_resolution = false;
let probe_paths = match (stat_sync)(&path) {
Some(SloppyImportsFsEntry::File) => {
return SloppyImportsResolution::None(specifier);
}
Some(SloppyImportsFsEntry::Dir) => {
is_dir_resolution = true;
// try to resolve at the index file
vec![
path.join("index.ts"),
path.join("index.js"),
path.join("index.mts"),
path.join("index.mjs"),
path.join("index.tsx"),
path.join("index.jsx"),
]
}
None => {
let media_type = MediaType::from_specifier(specifier);
let probe_media_type_types = match media_type {
MediaType::JavaScript => vec![MediaType::TypeScript, MediaType::Tsx],
MediaType::Jsx => vec![MediaType::Tsx],
MediaType::Mjs => vec![MediaType::Mts],
MediaType::Cjs => vec![MediaType::Cts],
MediaType::TypeScript
| MediaType::Mts
| MediaType::Cts
| MediaType::Dts
| MediaType::Dmts
| MediaType::Dcts
| MediaType::Tsx
| MediaType::Json
| MediaType::Wasm
| MediaType::TsBuildInfo
| MediaType::SourceMap => {
return SloppyImportsResolution::None(specifier)
}
MediaType::Unknown => {
is_no_ext_resolution = true;
vec![
MediaType::TypeScript,
MediaType::JavaScript,
MediaType::Tsx,
MediaType::Jsx,
MediaType::Mts,
MediaType::Mjs,
]
}
};
let old_path_str = path.to_string_lossy();
let old_path_str = match media_type {
MediaType::Unknown => old_path_str,
_ => match old_path_str.strip_suffix(media_type.as_ts_extension()) {
Some(s) => Cow::Borrowed(s),
None => return SloppyImportsResolution::None(specifier),
},
};
probe_media_type_types
.into_iter()
.map(|media_type| {
PathBuf::from(format!(
"{}{}",
old_path_str,
media_type.as_ts_extension()
))
})
.collect::<Vec<_>>()
}
};
for probe_path in probe_paths {
if (stat_sync)(&probe_path) == Some(SloppyImportsFsEntry::File) {
if let Ok(specifier) = ModuleSpecifier::from_file_path(probe_path) {
if is_dir_resolution {
return SloppyImportsResolution::Directory(specifier);
} else if is_no_ext_resolution {
return SloppyImportsResolution::NoExtension(specifier);
} else {
return SloppyImportsResolution::JsToTs(specifier);
}
}
}
}
SloppyImportsResolution::None(specifier)
}
pub fn resolve<'a>(
&self,
specifier: &'a ModuleSpecifier,
) -> SloppyImportsResolution<'a> {
Self::resolve_with_stat_sync(specifier, |path| {
self.stat_cache.stat_sync(path)
})
}
}
#[cfg(test)]
mod test {
use std::collections::BTreeMap;
use deno_runtime::deno_fs::RealFs;
use test_util::TestContext;
use super::*;
#[test]
fn test_resolve_package_json_dep() {
fn resolve(
specifier: &str,
deps: &BTreeMap<String, PackageReq>,
) -> Result<Option<String>, String> {
let deps = deps
.iter()
.map(|(key, value)| (key.to_string(), Ok(value.clone())))
.collect();
resolve_package_json_dep(specifier, &deps)
.map(|s| s.map(|s| s.to_string()))
.map_err(|err| err.to_string())
}
let deps = BTreeMap::from([
(
"package".to_string(),
PackageReq::from_str("package@1.0").unwrap(),
),
(
"package-alias".to_string(),
PackageReq::from_str("package@^1.2").unwrap(),
),
(
"@deno/test".to_string(),
PackageReq::from_str("@deno/test@~0.2").unwrap(),
),
]);
assert_eq!(
resolve("package", &deps).unwrap(),
Some("npm:package@1.0".to_string()),
);
assert_eq!(
resolve("package/some_path.ts", &deps).unwrap(),
Some("npm:package@1.0/some_path.ts".to_string()),
);
assert_eq!(
resolve("@deno/test", &deps).unwrap(),
Some("npm:@deno/test@~0.2".to_string()),
);
assert_eq!(
resolve("@deno/test/some_path.ts", &deps).unwrap(),
Some("npm:@deno/test@~0.2/some_path.ts".to_string()),
);
// matches the start, but doesn't have the same length or a path
assert_eq!(resolve("@deno/testing", &deps).unwrap(), None,);
// alias
assert_eq!(
resolve("package-alias", &deps).unwrap(),
Some("npm:package@^1.2".to_string()),
);
// non-existent bare specifier
assert_eq!(resolve("non-existent", &deps).unwrap(), None);
}
#[test]
fn test_unstable_sloppy_imports() {
fn resolve(specifier: &ModuleSpecifier) -> SloppyImportsResolution {
SloppyImportsResolver::resolve_with_stat_sync(specifier, |path| {
RealFs.stat_sync(path).ok().and_then(|stat| {
if stat.is_file {
Some(SloppyImportsFsEntry::File)
} else if stat.is_directory {
Some(SloppyImportsFsEntry::Dir)
} else {
None
}
})
})
}
let context = TestContext::default();
let temp_dir = context.temp_dir().path();
// scenarios like resolving ./example.js to ./example.ts
for (ext_from, ext_to) in [("js", "ts"), ("js", "tsx"), ("mjs", "mts")] {
let ts_file = temp_dir.join(format!("file.{}", ext_to));
ts_file.write("");
let ts_file_uri = ts_file.uri_file();
assert_eq!(
resolve(&ts_file.uri_file()),
SloppyImportsResolution::None(&ts_file_uri),
);
assert_eq!(
resolve(
&temp_dir
.uri_dir()
.join(&format!("file.{}", ext_from))
.unwrap()
),
SloppyImportsResolution::JsToTs(ts_file.uri_file()),
);
ts_file.remove_file();
}
// no extension scenarios
for ext in ["js", "ts", "js", "tsx", "jsx", "mjs", "mts"] {
let file = temp_dir.join(format!("file.{}", ext));
file.write("");
assert_eq!(
resolve(
&temp_dir
.uri_dir()
.join("file") // no ext
.unwrap()
),
SloppyImportsResolution::NoExtension(file.uri_file()),
);
file.remove_file();
}
// .ts and .js exists, .js specified (goes to specified)
{
let ts_file = temp_dir.join("file.ts");
ts_file.write("");
let js_file = temp_dir.join("file.js");
js_file.write("");
let js_file_uri = js_file.uri_file();
assert_eq!(
resolve(&js_file.uri_file()),
SloppyImportsResolution::None(&js_file_uri),
);
}
// resolving a directory to an index file
{
let routes_dir = temp_dir.join("routes");
routes_dir.create_dir_all();
let index_file = routes_dir.join("index.ts");
index_file.write("");
assert_eq!(
resolve(&routes_dir.uri_file()),
SloppyImportsResolution::Directory(index_file.uri_file()),
);
}
}
#[test]
fn test_sloppy_import_resolution_suggestion_message() {
// none
let url = ModuleSpecifier::parse("file:///dir/index.js").unwrap();
assert_eq!(
SloppyImportsResolution::None(&url).as_suggestion_message(),
None,
);
// directory
assert_eq!(
SloppyImportsResolution::Directory(
ModuleSpecifier::parse("file:///dir/index.js").unwrap()
)
.as_suggestion_message()
.unwrap(),
"Maybe specify path to 'index.js' file in directory instead"
);
// no ext
assert_eq!(
SloppyImportsResolution::NoExtension(
ModuleSpecifier::parse("file:///dir/index.mjs").unwrap()
)
.as_suggestion_message()
.unwrap(),
"Maybe add a '.mjs' extension"
);
// js to ts
assert_eq!(
SloppyImportsResolution::JsToTs(
ModuleSpecifier::parse("file:///dir/index.mts").unwrap()
)
.as_suggestion_message()
.unwrap(),
"Maybe change the extension to '.mts'"
);
}
}