use std::borrow::Borrow;
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::hash::{BuildHasher, Hash};
use std::io;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, PoisonError, RwLock};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use oxc_resolver::{
FileMetadata, ResolveError, ResolveOptions, ResolverGeneric, TsconfigDiscovery, TsconfigOptions, TsconfigReferences,
};
use serde_json::Value;
use skif_core::{FileSystem, HashMap, HashState, OsFs, slash};
use skif_js_plugins::bazel::{DeclaredOutputs, build_file, is_output_path, repository_root_within};
use skif_js_syntax::{DECLARATION_SUFFIXES, is_stylesheet};
use skif_workspace::Workspace;
use crate::compilers::Compilers;
use crate::manifest_paths::{ENTRY_FIELDS, map_to_source, resolve_exports, resolve_imports};
use crate::setup::Setup;
use crate::specifier::{
assumed_package, bin_link, has_foreign_scheme, is_builtin, node_modules_package, node_modules_specifier_package,
package_name, subpath,
};
use crate::tsconfig_paths::{ForeignPaths, TsConfigs, TsPaths, match_pattern};
pub(crate) const CODE_EXTENSIONS: &[&str] = &[
"js", "mjs", "cjs", "jsx", "ts", "mts", "cts", "tsx", "vue", "svelte", "astro",
];
pub(crate) const EXTRACTED_EXTENSIONS: &[&str] = &["marko", "mdx", "tsrx"];
pub(crate) fn is_code_file(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
let extension = extension.to_ascii_lowercase();
CODE_EXTENSIONS.contains(&extension.as_str()) || EXTRACTED_EXTENSIONS.contains(&extension.as_str())
})
}
pub(crate) fn is_read_file(path: &Path) -> bool {
is_code_file(path) || is_stylesheet(path)
}
struct ResolverFs {
fs: Arc<dyn FileSystem>,
}
impl oxc_resolver::FileSystem for ResolverFs {
fn new() -> Self {
Self { fs: Arc::new(OsFs) }
}
fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
self.fs.read_to_string(path).map(|text| text.as_bytes().to_vec())
}
fn read_to_string(&self, path: &Path) -> io::Result<String> {
self.fs.read_to_string(path).map(|text| text.to_string())
}
fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
let kind = self.fs.kind(path);
if kind.is_file {
Ok(FileMetadata::new(true, false, false))
} else if kind.is_dir {
Ok(FileMetadata::new(false, true, false))
} else {
Err(io::ErrorKind::NotFound.into())
}
}
fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
self.metadata(path)
}
fn read_link(&self, _path: &Path) -> Result<PathBuf, ResolveError> {
Err(io::Error::new(io::ErrorKind::InvalidInput, "not a symbolic link").into())
}
fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
Ok(path.to_path_buf())
}
}
pub(crate) fn join_normalized(base: &Path, relative: &str) -> PathBuf {
let mut path = base.to_path_buf();
for segment in relative.split(['/', '\\']) {
match segment {
"" | "." => {}
".." => {
path.pop();
}
other => path.push(other),
}
}
path
}
fn base_name(path: &Path) -> Option<&OsStr> {
let name = path.file_name()?;
name.to_str()
.and_then(|name| DECLARATION_SUFFIXES.iter().find_map(|suffix| name.strip_suffix(suffix)))
.map(OsStr::new)
.or_else(|| path.file_stem())
}
fn is_at(found: &Path, path: &Path) -> bool {
found.starts_with(path)
|| (found.parent() == path.parent()
&& base_name(found).is_some_and(|base| {
Some(base) == path.file_name() || (is_code_file(path) && Some(base) == path.file_stem())
}))
}
fn specifier_of(path: &Path) -> String {
let text = path.to_string_lossy();
if cfg!(windows) {
text.replace('\\', "/")
} else {
text.into_owned()
}
}
fn is_below(path: &Path, root: &Path) -> bool {
if cfg!(windows) {
let lower = |path: &Path| PathBuf::from(path.to_string_lossy().to_lowercase());
lower(path).starts_with(lower(root))
} else {
path.starts_with(root)
}
}
pub(crate) fn is_installed(path: &Path) -> bool {
path.components()
.any(|component| component.as_os_str() == "node_modules")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Resolved {
File(PathBuf),
Asset,
External(String),
Builtin,
Ignored,
Generated { dir: PathBuf, recursive: bool },
Unresolved,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Outcome {
pub target: Resolved,
pub package: Option<String>,
pub root_dirs: Option<PathBuf>,
}
struct Member {
dir: PathBuf,
manifest: Arc<Value>,
build_dirs: Vec<(PathBuf, PathBuf)>,
}
struct Alias {
find: String,
target: Option<PathBuf>,
}
struct AliasScope {
dir: PathBuf,
paths: Vec<(String, Vec<String>)>,
aliases: Vec<Alias>,
module_dirs: Vec<PathBuf>,
}
const FOREIGN_FILE_EXTENSIONS: &[&str] = &[
".avif", ".css", ".eot", ".gif", ".html", ".ico", ".jpeg", ".jpg", ".less", ".mp3", ".otf", ".png", ".sass",
".scss", ".sh", ".styl", ".stylus", ".svg", ".ttf", ".webp", ".woff", ".woff2", ".yaml", ".yml",
];
pub(crate) const EXTENSION_ALIASES: &[(&str, &[&str])] = &[
(".js", &[".ts", ".tsx", ".d.ts"]),
(".jsx", &[".tsx"]),
(".mjs", &[".mts", ".d.mts"]),
(".cjs", &[".cts", ".d.cts"]),
];
const VIRTUAL_ROOTS: &[&str] = &["$app", "$env", "$service-worker"];
impl AliasScope {
fn pattern_paths(&self, specifier: &str) -> Vec<PathBuf> {
if is_relative_or_absolute(specifier) {
return Vec::new();
}
let mut matched: Vec<(usize, &[String], &str)> = self
.paths
.iter()
.filter_map(|(pattern, targets)| {
let (rank, captured) = match_pattern(pattern, specifier)?;
Some((rank, targets.as_slice(), captured))
})
.collect();
matched.sort_by_key(|(rank, ..)| std::cmp::Reverse(*rank));
matched
.into_iter()
.flat_map(|(_, targets, captured)| {
targets
.iter()
.map(move |target| join_normalized(&self.dir, &target.replacen('*', captured, 1)))
})
.collect()
}
}
fn is_relative_or_absolute(specifier: &str) -> bool {
matches!(specifier, "." | "..")
|| specifier.starts_with("./")
|| specifier.starts_with("../")
|| specifier.starts_with('/')
}
fn alias_rest<'a>(specifier: &'a str, find: &str) -> Option<&'a str> {
if specifier == find {
return Some("");
}
specifier.strip_prefix(find)?.strip_prefix('/')
}
const MEMO_SHARDS: usize = 32;
fn memo_shard<T: Hash + ?Sized>(key: &T) -> usize {
usize::try_from(HashState.hash_one(key) >> 32).unwrap_or(0) % MEMO_SHARDS
}
fn memo_shards<M: Default>() -> Box<[RwLock<M>]> {
(0..MEMO_SHARDS).map(|_| RwLock::new(M::default())).collect()
}
struct Memo<K, V>(Box<[RwLock<HashMap<K, V>>]>);
impl<K: Eq + Hash, V: Clone> Memo<K, V> {
fn new() -> Self {
Self(memo_shards())
}
fn shard<Q>(&self, key: &Q) -> &RwLock<HashMap<K, V>>
where
Q: Hash + ?Sized,
{
&self.0[memo_shard(key)]
}
fn get<Q>(&self, key: &Q) -> Option<V>
where
K: Borrow<Q>,
Q: Eq + Hash + ?Sized,
{
self.shard(key)
.read()
.unwrap_or_else(PoisonError::into_inner)
.get(key)
.cloned()
}
fn get_or_compute<Q>(&self, key: &Q, compute: impl FnOnce() -> V) -> V
where
K: Borrow<Q>,
Q: Eq + Hash + ToOwned<Owned = K> + ?Sized,
{
if let Some(known) = self.get(key) {
return known;
}
let value = compute();
self.shard(key)
.write()
.unwrap_or_else(PoisonError::into_inner)
.insert(key.to_owned(), value.clone());
value
}
}
struct PairMemo<K, V>(Box<[PairShard<K, V>]>);
type PairShard<K, V> = RwLock<HashMap<K, HashMap<String, V>>>;
impl<K: Eq + Hash + Clone, V: Clone> PairMemo<K, V> {
fn new() -> Self {
Self(memo_shards())
}
fn get_or_compute<Q>(&self, first: &Q, specifier: &str, compute: impl FnOnce() -> V) -> V
where
K: Borrow<Q>,
Q: Eq + Hash + ToOwned<Owned = K> + ?Sized,
{
let shard = &self.0[memo_shard(&(first, specifier))];
if let Some(known) = shard
.read()
.unwrap_or_else(PoisonError::into_inner)
.get(first)
.and_then(|probes| probes.get(specifier))
{
return known.clone();
}
let value = compute();
shard
.write()
.unwrap_or_else(PoisonError::into_inner)
.entry(first.to_owned())
.or_default()
.insert(specifier.to_owned(), value.clone());
value
}
}
pub(crate) struct ModuleResolver {
script: Resolvers,
script_with_extensions: HashMap<PathBuf, Resolvers>,
root_script: Option<Resolvers>,
style: Resolvers,
fs: Arc<dyn FileSystem>,
root: PathBuf,
workspace_name: Option<String>,
bazel_root: Option<PathBuf>,
bazel_outputs: Memo<PathBuf, Option<Arc<(PathBuf, DeclaredOutputs)>>>,
ignores: Vec<(PathBuf, Gitignore)>,
package_dirs: Vec<PathBuf>,
members: BTreeMap<String, Member>,
member_dirs: HashMap<PathBuf, String>,
scopes: Vec<AliasScope>,
scope_index: HashMap<PathBuf, usize>,
module_dir_chains: Vec<Vec<PathBuf>>,
scope_chains: Memo<PathBuf, Arc<[usize]>>,
has_module_dirs: bool,
conditions: Vec<String>,
directory_packages: Memo<PathBuf, Option<Arc<Member>>>,
module_dir_probes: PairMemo<usize, Option<Resolved>>,
style_module_dir_probes: PairMemo<usize, Option<Resolved>>,
member_files: PairMemo<String, Option<PathBuf>>,
nearest_paths: Memo<PathBuf, Option<Arc<TsPaths>>>,
tsconfigs: TsConfigs,
ungenerated_configs: Memo<PathBuf, bool>,
declared_imports: Memo<PathBuf, bool>,
ignore_ceiling: PathBuf,
dir_ignores: Memo<PathBuf, Option<Arc<Gitignore>>>,
compilers: Arc<Compilers>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Kind {
Script,
Style,
}
struct Resolvers {
with_tsconfig: ResolverGeneric<ResolverFs>,
plain: ResolverGeneric<ResolverFs>,
tsconfigs: Arc<TsconfigFailures>,
}
struct TsconfigFailures {
fs: Arc<dyn FileSystem>,
by_directory: Memo<PathBuf, Option<ResolveError>>,
by_file: Memo<PathBuf, Option<ResolveError>>,
seen: AtomicBool,
}
impl TsconfigFailures {
fn directory(importer: &Path) -> Option<&Path> {
let directory = importer.parent()?;
let text = importer.as_os_str().as_encoded_bytes();
let plain = importer.is_absolute()
&& !text.contains(&b'#')
&& !text.contains(&b'?')
&& importer.components().all(|component| match component {
Component::Normal(name) => name != "node_modules",
Component::RootDir | Component::Prefix(_) => true,
Component::CurDir | Component::ParentDir => false,
});
plain.then_some(directory)
}
fn known(&self, resolver: &ResolverGeneric<ResolverFs>, importer: &Path) -> Option<ResolveError> {
if !self.seen.load(Ordering::Relaxed) {
return None;
}
self.of_directory(resolver, Self::directory(importer)?)
}
fn record(&self, resolver: &ResolverGeneric<ResolverFs>, importer: &Path) {
self.seen.store(true, Ordering::Relaxed);
if let Some(directory) = Self::directory(importer) {
self.of_directory(resolver, directory);
}
}
fn of_directory(&self, resolver: &ResolverGeneric<ResolverFs>, directory: &Path) -> Option<ResolveError> {
self.by_directory.get_or_compute(directory, || {
let config = directory
.ancestors()
.map(|dir| dir.join("tsconfig.json"))
.find(|config| self.fs.is_file(config))?;
self.by_file
.get_or_compute(&config, || match resolver.resolve_tsconfig(&config) {
Ok(_) => None,
Err(ResolveError::TsconfigLoadFailed { source, .. })
if matches!(*source, ResolveError::IOError(_)) =>
{
None
}
Err(error) => Some(error),
})
})
}
}
impl Resolvers {
fn new(fs: &Arc<dyn FileSystem>, options: impl Fn(bool) -> ResolveOptions) -> Self {
let with_tsconfig = ResolverGeneric::new_with_file_system(ResolverFs { fs: Arc::clone(fs) }, options(true));
let plain = with_tsconfig.clone_with_options(options(false));
Self {
with_tsconfig,
plain,
tsconfigs: Arc::new(TsconfigFailures {
fs: Arc::clone(fs),
by_directory: Memo::new(),
by_file: Memo::new(),
seen: AtomicBool::new(false),
}),
}
}
fn sharing_cache(&self, options: impl Fn(bool) -> ResolveOptions) -> Self {
Self {
with_tsconfig: self.with_tsconfig.clone_with_options(options(true)),
plain: self.with_tsconfig.clone_with_options(options(false)),
tsconfigs: Arc::clone(&self.tsconfigs),
}
}
fn attempt(&self, importer: &Path, specifier: &str) -> Result<PathBuf, ResolveError> {
let with_tsconfig = match self.tsconfigs.known(&self.with_tsconfig, importer) {
Some(error) => Err(error),
None => self.with_tsconfig.resolve_file(importer, specifier),
};
match with_tsconfig {
Ok(resolution) => Ok(resolution.path().to_path_buf()),
Err(error @ (ResolveError::NotFound(_) | ResolveError::MatchedAliasNotFound(..))) => Err(error),
Err(error) => {
if matches!(
error,
ResolveError::TsconfigNotFound(_)
| ResolveError::TsconfigLoadFailed { .. }
| ResolveError::TsconfigCircularExtend(_)
| ResolveError::TsconfigSelfReference(_)
) {
self.tsconfigs.record(&self.with_tsconfig, importer);
}
self.plain
.resolve_file(importer, specifier)
.map(|resolution| resolution.path().to_path_buf())
}
}
}
}
fn chain_key<'p>(path: &'p Path, scope_index: &HashMap<PathBuf, usize>) -> &'p Path {
match path.parent() {
Some(parent) if !scope_index.contains_key(path) => parent,
_ => path,
}
}
fn module_dir_chains(scopes: &[AliasScope]) -> Vec<Vec<PathBuf>> {
scopes
.iter()
.map(|innermost| {
scopes
.iter()
.filter(|scope| innermost.dir.starts_with(&scope.dir))
.flat_map(|scope| scope.module_dirs.iter().cloned())
.collect()
})
.collect()
}
fn read_gitignore(fs: &dyn FileSystem, dir: &Path) -> Option<(PathBuf, Gitignore)> {
let text = fs.read_to_string(&dir.join(".gitignore")).ok()?;
let mut builder = GitignoreBuilder::new(dir);
for line in text.lines() {
let _ = builder.add_line(None, line);
}
Some((dir.to_path_buf(), builder.build().ok()?))
}
fn style_forms(specifier: &str) -> [String; 3] {
let trimmed = specifier.trim_end_matches('/');
let (dir, name) = trimmed
.rsplit_once('/')
.map_or(("", trimmed), |(dir, name)| (dir, name));
let prefix = if dir.is_empty() {
String::new()
} else {
format!("{dir}/")
};
[
specifier.to_owned(),
format!("{prefix}_{name}"),
format!("{prefix}{name}/_index"),
]
}
fn style_options(tsconfig: bool) -> ResolveOptions {
let owned = |items: &[&str]| items.iter().map(|item| (*item).to_owned()).collect::<Vec<_>>();
ResolveOptions {
tsconfig: tsconfig.then_some(TsconfigDiscovery::Auto),
extensions: owned(&[".scss", ".sass", ".css", ".less", ".pcss", ".styl", ".stylus"]),
condition_names: owned(&["style", "sass", "less", "import", "node", "default"]),
main_fields: owned(&["style", "sass", "less", "main"]),
symlinks: false,
node_path: false,
..ResolveOptions::default()
}
}
fn options(tsconfig: bool, conditions: &[String], extensions: &[String]) -> ResolveOptions {
let owned = |items: &[&str]| items.iter().map(|item| (*item).to_owned()).collect::<Vec<_>>();
ResolveOptions {
tsconfig: tsconfig.then_some(TsconfigDiscovery::Auto),
extensions: owned(&[
".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".json", ".vue", ".svelte", ".astro", ".d.ts",
])
.into_iter()
.chain(extensions.iter().cloned())
.collect(),
extension_alias: vec![
(".js".to_owned(), owned(&[".ts", ".tsx", ".js", ".d.ts"])),
(".jsx".to_owned(), owned(&[".tsx", ".jsx"])),
(".mjs".to_owned(), owned(&[".mts", ".mjs", ".d.mts"])),
(".cjs".to_owned(), owned(&[".cts", ".cjs", ".d.cts"])),
],
condition_names: owned(&["import", "require", "node", "default", "types"])
.into_iter()
.chain(conditions.iter().cloned())
.collect(),
main_fields: owned(&["module", "main"]),
symlinks: false,
node_path: false,
..ResolveOptions::default()
}
}
impl ModuleResolver {
pub(crate) fn new(fs: Arc<dyn FileSystem>, workspace: &Workspace, setup: &Setup) -> Self {
let mut conditions: Vec<String> = setup
.packages
.iter()
.flatten()
.flat_map(|package| package.conditions.iter().cloned())
.collect();
conditions.sort();
conditions.dedup();
let script = Resolvers::new(&fs, |tsconfig| options(tsconfig, &conditions, &[]));
let script_with_extensions = setup
.packages
.iter()
.flatten()
.filter(|package| !package.extensions.is_empty())
.map(|package| {
let resolvers = script.sharing_cache(|tsconfig| options(tsconfig, &conditions, &package.extensions));
(package.dir.clone(), resolvers)
})
.collect();
let root_script = setup
.root_tsconfig
.as_ref()
.filter(|config| fs.is_file(config))
.map(|config| {
let extensions = setup
.packages
.iter()
.flatten()
.find(|package| package.dir == workspace.root)
.map_or(&[][..], |package| package.extensions.as_slice());
script.sharing_cache(|tsconfig| {
let mut options = options(tsconfig, &conditions, extensions);
if tsconfig {
options.tsconfig = Some(TsconfigDiscovery::Manual(TsconfigOptions {
config_file: config.clone(),
references: TsconfigReferences::Auto,
}));
}
options
})
});
let style = script.sharing_cache(style_options);
let members: BTreeMap<String, Member> = workspace
.all_packages()
.filter_map(|package| {
let name = package.name.clone()?;
let dir = workspace.root.join(&package.dir);
let build_dirs = setup.package(package.id).map_or_default(|setup| {
setup
.build_dirs
.iter()
.map(|dir_pair| {
(
join_normalized(&dir, &dir_pair.out_dir),
join_normalized(&dir, &dir_pair.root_dir),
)
})
.collect()
});
Some((
name,
Member {
dir,
manifest: package.manifest.shared_value(),
build_dirs,
},
))
})
.collect();
let mut member_dirs: HashMap<PathBuf, String> = HashMap::default();
for (name, member) in &members {
member_dirs.entry(member.dir.clone()).or_insert_with(|| name.clone());
}
let mut scopes: Vec<AliasScope> = setup
.packages
.iter()
.flatten()
.map(|package| {
let mut aliases: Vec<Alias> = package
.aliases
.iter()
.map(|alias| Alias {
find: alias.find.clone(),
target: alias
.target
.as_deref()
.map(|target| join_normalized(&package.dir, target)),
})
.collect();
aliases.push(Alias {
find: "$lib".to_owned(),
target: Some(join_normalized(&package.dir, "src/lib")),
});
aliases.sort_by_key(|alias| std::cmp::Reverse(alias.find.len()));
let module_dirs = package
.module_dirs
.iter()
.map(|dir| join_normalized(&package.dir, dir))
.filter(|dir| !dir.components().any(|part| part.as_os_str() == "node_modules"))
.collect();
AliasScope {
dir: package.dir.clone(),
paths: package
.paths
.iter()
.map(|(pattern, targets)| (pattern.clone(), targets.clone()))
.collect(),
aliases,
module_dirs,
}
})
.collect();
scopes.sort_by_key(|scope| std::cmp::Reverse(scope.dir.components().count()));
let scope_index = scopes
.iter()
.enumerate()
.map(|(position, scope)| (scope.dir.clone(), position))
.collect();
let has_module_dirs = scopes.iter().any(|scope| !scope.module_dirs.is_empty());
let module_dir_chains = if has_module_dirs {
module_dir_chains(&scopes)
} else {
Vec::new()
};
let mut ignore_dirs = vec![workspace.root.clone()];
for package in workspace.all_packages() {
let dir = workspace.root.join(&package.dir);
if !ignore_dirs.contains(&dir) {
ignore_dirs.push(dir);
}
}
let ignores = ignore_dirs
.iter()
.filter_map(|dir| read_gitignore(fs.as_ref(), dir))
.collect();
let mut package_dirs = ignore_dirs;
package_dirs.sort_by(|a, b| {
b.components()
.count()
.cmp(&a.components().count())
.then_with(|| a.cmp(b))
});
let tsconfigs = TsConfigs::new(
Arc::clone(&fs),
workspace.root.clone(),
members
.iter()
.map(|(name, member)| (name.clone(), member.dir.clone()))
.collect(),
)
.with_root_config(setup.root_tsconfig.clone());
let git_root = setup.git_root.clone();
let ignore_ceiling = git_root.clone().unwrap_or_else(|| workspace.root.clone());
Self {
script,
script_with_extensions,
root_script,
style,
workspace_name: skif_workspace::bazel_workspace_name(fs.as_ref(), &workspace.root),
bazel_root: repository_root_within(fs.as_ref(), &workspace.root, git_root.as_deref()),
bazel_outputs: Memo::new(),
ignores,
package_dirs,
fs,
root: workspace.root.clone(),
members,
member_dirs,
scopes,
scope_index,
module_dir_chains,
scope_chains: Memo::new(),
has_module_dirs,
conditions,
directory_packages: Memo::new(),
module_dir_probes: PairMemo::new(),
style_module_dir_probes: PairMemo::new(),
member_files: PairMemo::new(),
nearest_paths: Memo::new(),
tsconfigs,
ungenerated_configs: Memo::new(),
declared_imports: Memo::new(),
ignore_ceiling,
dir_ignores: Memo::new(),
compilers: Arc::clone(&setup.compilers),
}
}
fn classify(&self, path: PathBuf) -> Resolved {
if self.in_bazel_output(&path) {
return Resolved::Ignored;
}
let module = is_read_file(&path) || self.compilers.handles(&path);
if module && (is_below(&path, &self.root) || !is_installed(&path)) {
Resolved::File(path)
} else {
Resolved::Asset
}
}
fn in_bazel_output(&self, path: &Path) -> bool {
self.bazel_root
.as_deref()
.is_some_and(|root| is_output_path(root, path))
}
fn declared_bazel_output(&self, path: &Path) -> bool {
let (Some(root), Some(directory)) = (self.bazel_root.as_deref(), path.parent()) else {
return false;
};
if !directory.starts_with(root) || self.in_bazel_output(path) {
return false;
}
let nearest = self.bazel_outputs.get_or_compute(directory, || {
let (dir, file) = directory
.ancestors()
.take_while(|dir| dir.starts_with(root))
.find_map(|dir| build_file(self.fs.as_ref(), dir).map(|file| (dir, file)))?;
let source = self.fs.read_to_string(&file).ok()?;
Some(Arc::new((dir.to_path_buf(), DeclaredOutputs::read(&source))))
});
nearest.is_some_and(|found| {
let (dir, outputs) = &*found;
path.strip_prefix(dir)
.is_ok_and(|relative| outputs.declares(&slash(relative)))
})
}
fn script_resolvers(&self, within: &Path) -> &Resolvers {
if self.script_with_extensions.is_empty() && self.root_script.is_none() {
return &self.script;
}
let innermost = self.scopes_of(within).next().map(|(_, scope)| &scope.dir);
if let (Some(root_script), Some(dir)) = (&self.root_script, innermost)
&& *dir == self.root
{
return root_script;
}
innermost
.and_then(|dir| self.script_with_extensions.get(dir))
.unwrap_or(&self.script)
}
fn resolvers(&self, kind: Kind, within: &Path) -> &Resolvers {
match kind {
Kind::Script => self.script_resolvers(within),
Kind::Style => &self.style,
}
}
fn is_generated(&self, path: &Path) -> bool {
if self
.ignores
.iter()
.any(|(dir, ignore)| path.starts_with(dir) && ignore.matched_path_or_any_parents(path, false).is_ignore())
{
return true;
}
let Some(parent) = path.parent() else {
return false;
};
let ceiling = &self.ignore_ceiling;
parent
.ancestors()
.take_while(|dir| dir.starts_with(ceiling))
.filter(|dir| !self.ignores.iter().any(|(base, _)| base.as_path() == *dir))
.any(|dir| {
self.dir_ignores
.get_or_compute(dir, || {
read_gitignore(self.fs.as_ref(), dir).map(|(_, ignore)| Arc::new(ignore))
})
.is_some_and(|ignore| ignore.matched_path_or_any_parents(path, false).is_ignore())
})
}
fn member_at(&self, dir: &Path) -> Option<&Member> {
self.member_dirs.get(dir).and_then(|name| self.members.get(name))
}
fn declares_imports(&self, importer: &Path) -> bool {
let Some(directory) = importer.parent() else {
return false;
};
self.declared_imports.get_or_compute(directory, || {
for dir in directory.ancestors().take_while(|dir| dir.starts_with(&self.root)) {
if let Some(member) = self.member_at(dir) {
return member.manifest.get("imports").is_some();
}
if let Ok(text) = self.fs.read_to_string(&dir.join("package.json")) {
return serde_json::from_str::<Value>(&text)
.map_or(true, |manifest| manifest.get("imports").is_some());
}
}
false
})
}
pub(crate) fn ts_paths_map(&self, importer: &Path, specifier: &str) -> bool {
let Some(directory) = importer.parent() else {
return false;
};
self.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))
.is_some_and(|config| config.maps(specifier))
}
pub(crate) fn package_paths(&self) -> ForeignPaths {
let configs: Vec<Arc<TsPaths>> = self
.package_dirs
.iter()
.filter_map(|dir| {
self.nearest_paths
.get_or_compute(dir.as_path(), || self.tsconfigs.nearest(dir).map(Arc::new))
})
.collect();
ForeignPaths::new(&self.root, &configs)
}
pub(crate) fn maps_to_source(&self, config: &TsPaths, pattern: &str, specifier: &str) -> bool {
config
.pattern_candidates(pattern, specifier)
.into_iter()
.filter(|path| node_modules_package(path).is_none())
.any(|path| {
let directory = path.parent().unwrap_or(&self.root);
self.resolve_path(Kind::Script, directory, &path).is_some_and(|found| {
node_modules_package(&found).is_none() && matches!(self.classify(found), Resolved::File(_))
})
})
}
pub(crate) fn installed(&self, importer: &Path, name: &str) -> bool {
importer
.ancestors()
.skip(1)
.take_while(|dir| dir.starts_with(&self.root))
.any(|dir| {
self.fs
.is_file(&dir.join("node_modules").join(name).join("package.json"))
})
}
fn missing(&self, kind: Kind, importer: &Path, specifier: &str) -> Resolved {
if specifier.starts_with('#') && !self.declares_imports(importer) && !self.ts_paths_map(importer, specifier) {
return Resolved::Ignored;
}
let root_relative = specifier.starts_with('/');
if !specifier.starts_with('.') && !root_relative {
return Resolved::Unresolved;
}
let extension = Path::new(specifier)
.extension()
.and_then(|extension| extension.to_str())
.map(|extension| format!(".{}", extension.to_ascii_lowercase()));
if kind == Kind::Script
&& extension
.as_deref()
.is_some_and(|extension| extension == ".json" || FOREIGN_FILE_EXTENSIONS.contains(&extension))
{
return Resolved::Ignored;
}
if root_relative {
return Resolved::Unresolved;
}
let Some(dir) = importer.parent() else {
return Resolved::Unresolved;
};
let candidate = join_normalized(dir, specifier);
let aliased = extension.as_deref().and_then(|extension| {
let (_, aliases) = EXTENSION_ALIASES.iter().find(|(from, _)| *from == extension)?;
let stem = specifier.get(..specifier.len() - extension.len())?;
Some(
aliases
.iter()
.map(move |alias| join_normalized(dir, &format!("{stem}{alias}"))),
)
});
let implied = extension
.as_deref()
.is_none_or(|extension| !CODE_EXTENSIONS.contains(&&extension[1..]))
.then(|| {
CODE_EXTENSIONS.iter().flat_map(|code| {
let mut sibling = candidate.as_os_str().to_owned();
sibling.push(format!(".{code}"));
[PathBuf::from(sibling), candidate.join(format!("index.{code}"))]
})
});
let generated = std::iter::once(candidate.clone())
.chain(aliased.into_iter().flatten())
.chain(implied.into_iter().flatten())
.find(|path| self.is_generated(path));
match generated {
Some(_) if kind == Kind::Style => Resolved::Ignored,
Some(file) => self.generated_scope(importer, &candidate, &file),
None if kind == Kind::Script
&& (self.generated_in_root_dir(dir, specifier)
|| self.ungenerated_route_types(dir, specifier)
|| self.declared_bazel_output(&candidate)) =>
{
Resolved::Ignored
}
None => Resolved::Unresolved,
}
}
fn generated_in_root_dir(&self, directory: &Path, specifier: &str) -> bool {
self.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))
.is_some_and(|config| {
config.root_dir_candidates(directory, specifier).iter().any(|path| {
if self.in_bazel_output(path) {
return false;
}
let absent_root = config
.root_dirs()
.iter()
.filter(|root| path.starts_with(root))
.max_by_key(|root| root.components().count())
.is_some_and(|root| !self.fs.is_dir(root));
absent_root && self.is_generated(path)
})
})
}
fn ungenerated_route_types(&self, directory: &Path, specifier: &str) -> bool {
matches!(specifier, "./$types" | "./$types.js" | "./$types.d.ts")
&& self.ungenerated_configs.get_or_compute(directory, || {
self.tsconfigs
.absent_extends(directory)
.iter()
.any(|config| self.is_generated(config))
})
}
fn generated_scope(&self, importer: &Path, named: &Path, file: &Path) -> Resolved {
let Some(dir) = file.parent() else {
return Resolved::Ignored;
};
let ignored_dir = self.ignores.iter().any(|(base, ignore)| {
dir.starts_with(base) && dir != base && ignore.matched_path_or_any_parents(dir, true).is_ignore()
});
let (dir, recursive) = if ignored_dir {
(dir, true)
} else if dir == named {
(named.parent().unwrap_or(named), true)
} else {
(dir, false)
};
let package = self
.package_dirs
.iter()
.find(|package| importer.starts_with(package))
.map_or(self.root.as_path(), PathBuf::as_path);
if !dir.starts_with(package) || dir == package || dir == package.join("src") {
return Resolved::Ignored;
}
Resolved::Generated {
dir: dir.to_path_buf(),
recursive,
}
}
fn aliased(&self, found: Resolved, member: Option<String>, (importer, specifier): (&Path, &str)) -> Outcome {
let package = match &found {
Resolved::External(name) => Some(name.clone()),
_ => member.filter(|member| {
let path = match &found {
Resolved::File(path) => Some(path.clone()),
Resolved::Generated { dir, .. } => Some(dir.clone()),
_ => self.alias_candidate(importer, specifier),
};
self.members
.get(member.as_str())
.is_some_and(|known| path.is_none_or(|path| path.starts_with(&known.dir)))
}),
};
Outcome {
target: found,
package,
root_dirs: None,
}
}
fn alias_candidate(&self, importer: &Path, specifier: &str) -> Option<PathBuf> {
self.scopes_of(importer)
.find_map(|(_, scope)| {
scope.pattern_paths(specifier).into_iter().next().or_else(|| {
scope.aliases.iter().find_map(|alias| {
let rest = alias_rest(specifier, &alias.find)?;
alias.target.as_ref().map(|target| join_normalized(target, rest))
})
})
})
.or_else(|| {
let directory = importer.parent()?;
self.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))?
.candidates(specifier)
.into_iter()
.next()
})
}
pub(crate) fn resolve(&self, importer: &Path, specifier: &str) -> Outcome {
let outcome = |target, package| Outcome {
target,
package,
root_dirs: None,
};
if is_builtin(specifier) {
return outcome(Resolved::Builtin, None);
}
if has_foreign_scheme(specifier) {
return outcome(Resolved::Ignored, None);
}
if bin_link(specifier).is_some() {
return outcome(Resolved::Asset, None);
}
self.resolve_module(Kind::Script, importer, specifier)
}
fn resolve_module(&self, kind: Kind, importer: &Path, specifier: &str) -> Outcome {
let outcome = |target, package| Outcome {
target,
package,
root_dirs: None,
};
if let Some(name) = node_modules_specifier_package(specifier) {
if let Ok(path) = self.resolvers(kind, importer).attempt(importer, specifier)
&& node_modules_package(&path).is_none()
{
return outcome(self.classify(path), None);
}
return self.package_target(&name, specifier);
}
let name = package_name(specifier);
if name.is_some()
&& let Some(found) = self
.through_workspace_name(specifier)
.or_else(|| self.through_module_dirs(kind, importer, specifier))
{
return outcome(found, None);
}
let member_name = name
.as_ref()
.filter(|name| self.members.contains_key(name.as_str()))
.cloned();
if let Ok(path) = self.resolvers(kind, importer).attempt(importer, specifier) {
if let Some(installed) = node_modules_package(&path) {
let package = name.unwrap_or(installed);
return self.package_target(&package, specifier);
}
let root_dirs = self.another_root_dir(importer, specifier, &path);
let package = member_name.filter(|member| {
self.members
.get(member.as_str())
.is_some_and(|known| path.starts_with(&known.dir))
});
return Outcome {
target: self.classify(path),
package,
root_dirs,
};
}
if specifier.starts_with('.') {
if let Some((found, root)) = self.through_root_dirs(importer, specifier) {
return Outcome {
target: found,
package: None,
root_dirs: Some(root),
};
}
if let Some(found) = self.through_relative_package_directory(importer, specifier) {
return outcome(found, None);
}
}
if specifier.starts_with('#')
&& let Some(found) = self.through_imports(importer, specifier)
{
return outcome(found, None);
}
let aliased = self.through_alias(kind, importer, specifier);
if aliased
.as_ref()
.is_none_or(|resolved| matches!(resolved, Resolved::Unresolved))
&& let Some(found) = self.through_nearest_tsconfig(kind, importer, specifier)
{
return self.aliased(found, member_name, (importer, specifier));
}
if let Some(found) = aliased
.as_ref()
.filter(|resolved| !matches!(resolved, Resolved::Unresolved))
{
return self.aliased(found.clone(), member_name, (importer, specifier));
}
if aliased.is_some()
&& name.is_none()
&& let Some(found) = self.through_module_dirs(kind, importer, specifier)
{
return outcome(found, None);
}
let assumed = || assumed_package(specifier).map(|package| self.package_target(&package, specifier));
match (aliased, name) {
(Some(Resolved::Unresolved), _) => assumed().unwrap_or_else(|| outcome(Resolved::Unresolved, None)),
(Some(aliased), _) => outcome(aliased, None),
(None, Some(name)) => self.package_target(&name, specifier),
(None, None) => assumed().unwrap_or_else(|| outcome(self.missing(kind, importer, specifier), None)),
}
}
fn local_style(&self, importer: &Path, specifier: &str) -> Option<PathBuf> {
style_forms(specifier)
.iter()
.find_map(|candidate| self.style.attempt(importer, candidate).ok())
}
fn resolve_path(&self, kind: Kind, directory: &Path, path: &Path) -> Option<PathBuf> {
let plain = &self.resolvers(kind, directory).plain;
match kind {
Kind::Script => plain
.resolve(directory, &specifier_of(path))
.ok()
.map(|resolution| resolution.path().to_path_buf()),
Kind::Style => style_forms(&specifier_of(path))
.iter()
.find_map(|candidate| plain.resolve(directory, candidate).ok())
.map(|resolution| resolution.path().to_path_buf()),
}
}
fn root_relative_style(&self, importer: &Path, specifier: &str) -> Resolved {
if let Some(path) = self.local_style(importer, specifier) {
return self.classify(path);
}
let rest = specifier.trim_start_matches('/');
let package_dir = self
.scopes_of(importer)
.next()
.map_or(self.root.as_path(), |(_, scope)| scope.dir.as_path());
[
package_dir.join(rest),
self.root.join(rest),
package_dir.join("public").join(rest),
]
.iter()
.find_map(|candidate| self.local_style(importer, &candidate.to_string_lossy()))
.map_or(Resolved::Ignored, |path| self.classify(path))
}
pub(crate) fn resolve_style(&self, importer: &Path, specifier: &str) -> Outcome {
let outcome = |target, package| Outcome {
target,
package,
root_dirs: None,
};
if has_foreign_scheme(specifier) {
return outcome(Resolved::Ignored, None);
}
if specifier.starts_with('/') {
return outcome(self.root_relative_style(importer, specifier), None);
}
let relative = specifier.starts_with('.');
let local = if relative {
specifier.to_owned()
} else {
format!("./{specifier}")
};
if let Some(path) = self.local_style(importer, &local) {
return outcome(self.classify(path), None);
}
if relative {
return outcome(self.missing(Kind::Style, importer, specifier), None);
}
self.resolve_module(Kind::Style, importer, specifier)
}
fn through_workspace_name(&self, specifier: &str) -> Option<Resolved> {
let name = self.workspace_name.as_deref()?;
let rest = specifier.strip_prefix(name)?.strip_prefix('/')?;
if rest.is_empty() {
return None;
}
let path = join_normalized(&self.root, rest);
self.script
.plain
.resolve(&self.root, &specifier_of(&path))
.ok()
.map(|resolution| self.classify(resolution.path().to_path_buf()))
}
fn scopes_of(&self, importer: &Path) -> impl Iterator<Item = (usize, &AliasScope)> + '_ {
let chain = self
.scope_chains
.get_or_compute(chain_key(importer, &self.scope_index), || {
importer
.ancestors()
.filter_map(|dir| self.scope_index.get(dir).copied())
.collect()
});
(0..chain.len()).map(move |at| {
let position = chain[at];
(position, &self.scopes[position])
})
}
fn through_module_dirs(&self, kind: Kind, importer: &Path, specifier: &str) -> Option<Resolved> {
if !self.has_module_dirs {
return None;
}
let (position, innermost) = self.scopes_of(importer).next()?;
let probes = match kind {
Kind::Script => &self.module_dir_probes,
Kind::Style => &self.style_module_dir_probes,
};
probes.get_or_compute(&position, specifier, || {
self.module_dir_chains[position].iter().find_map(|dir| {
let path = join_normalized(dir, specifier);
self.resolve_path(kind, &innermost.dir, &path)
.map(|found| self.classify(found))
})
})
}
fn through_nearest_tsconfig(&self, kind: Kind, importer: &Path, specifier: &str) -> Option<Resolved> {
let directory = importer.parent()?;
let config = self
.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))?;
config.candidates(specifier).into_iter().find_map(|path| {
if let Some(package) = node_modules_package(&path) {
return Some(Resolved::External(package));
}
self.resolve_path(kind, directory, &path)
.map(|found| self.classify(found))
})
}
fn another_root_dir(&self, importer: &Path, specifier: &str, found: &Path) -> Option<PathBuf> {
if !specifier.starts_with('.') {
return None;
}
let directory = importer.parent()?;
if is_at(found, &join_normalized(directory, specifier)) {
return None;
}
let config = self
.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))?;
config
.root_dir_targets(directory, specifier)
.into_iter()
.find(|(_, candidate)| is_at(found, candidate))
.map(|(root, _)| root.to_path_buf())
}
fn through_root_dirs(&self, importer: &Path, specifier: &str) -> Option<(Resolved, PathBuf)> {
let directory = importer.parent()?;
let config = self
.nearest_paths
.get_or_compute(directory, || self.tsconfigs.nearest(directory).map(Arc::new))?;
config
.root_dir_targets(directory, specifier)
.into_iter()
.find_map(|(root, path)| {
self.resolve_path(Kind::Script, directory, &path)
.map(|found| (self.classify(found), root.to_path_buf()))
})
}
fn through_imports(&self, importer: &Path, specifier: &str) -> Option<Resolved> {
let member = self
.members
.values()
.filter(|member| importer.starts_with(&member.dir))
.max_by_key(|member| member.dir.components().count())?;
let imports = member.manifest.get("imports")?;
let target = resolve_imports(imports, specifier, &self.conditions).filter(|target| target.starts_with("./"))?;
self.map_member_target(member, &target).map(|path| self.classify(path))
}
fn through_alias(&self, kind: Kind, importer: &Path, specifier: &str) -> Option<Resolved> {
let root = specifier.split('/').next().unwrap_or(specifier);
if VIRTUAL_ROOTS.contains(&root) {
return Some(Resolved::Ignored);
}
let mut computed = false;
let mut matched = false;
let directory = importer.parent()?;
for (_, scope) in self.scopes_of(importer) {
for path in scope.pattern_paths(specifier) {
matched = true;
if let Some(found) = self.follow_alias(kind, directory, &path, (path.as_path(), "")) {
return Some(found);
}
}
for alias in &scope.aliases {
let Some(rest) = alias_rest(specifier, &alias.find) else {
continue;
};
matched = true;
let Some(target) = &alias.target else {
computed = true;
continue;
};
let path = join_normalized(target, rest);
if let Some(found) = self.follow_alias(kind, directory, &path, (target.as_path(), rest)) {
return Some(found);
}
}
}
match (matched, computed) {
(false, _) => None,
(true, true) => Some(Resolved::Ignored),
(true, false) => Some(Resolved::Unresolved),
}
}
fn follow_alias(&self, kind: Kind, directory: &Path, path: &Path, within: (&Path, &str)) -> Option<Resolved> {
if let Some(package) = node_modules_package(path) {
return Some(Resolved::External(package));
}
if let Some(found) = self.resolve_path(kind, directory, path) {
return Some(self.classify(found));
}
if let Some(found) = self.through_package_directory(within.0, within.1) {
return Some(found);
}
self.is_generated(path).then_some(Resolved::Ignored)
}
fn through_package_directory(&self, target: &Path, rest: &str) -> Option<Resolved> {
let adhoc;
let member = if let Some(known) = self.member_at(target) {
known
} else {
adhoc = self.directory_package(target)?;
&*adhoc
};
match self.member_file(member, (!rest.is_empty()).then_some(rest)) {
Some(path) => Some(self.classify(path)),
None if rest.is_empty() => Some(Resolved::Ignored),
None => None,
}
}
fn through_relative_package_directory(&self, importer: &Path, specifier: &str) -> Option<Resolved> {
let directory = join_normalized(importer.parent()?, specifier);
self.through_package_directory(&directory, "")
}
fn directory_package(&self, dir: &Path) -> Option<Arc<Member>> {
self.directory_packages.get_or_compute(dir, || {
let text = self.fs.read_to_string(&dir.join("package.json")).ok()?;
Some(Arc::new(Member {
dir: dir.to_path_buf(),
manifest: Arc::new(serde_json::from_str(&text).ok()?),
build_dirs: Vec::new(),
}))
})
}
fn package_target(&self, name: &str, specifier: &str) -> Outcome {
let member_file = self.members.get(name).and_then(|member| {
self.member_files
.get_or_compute(name, specifier, || self.member_file(member, subpath(specifier, name)))
});
let target = member_file.map_or_else(|| Resolved::External(name.to_owned()), |path| self.classify(path));
Outcome {
target,
package: Some(name.to_owned()),
root_dirs: None,
}
}
fn map_member_target(&self, member: &Member, target: &str) -> Option<PathBuf> {
let published = join_normalized(&member.dir, target);
let declared = member.build_dirs.iter().chain(
self.members
.values()
.filter(|other| other.dir != member.dir)
.flat_map(|other| other.build_dirs.iter()),
);
for (out_dir, root_dir) in declared {
let Ok(rest) = published.strip_prefix(out_dir) else {
continue;
};
let rest = rest.to_string_lossy().replace('\\', "/");
let source = join_normalized(root_dir, &rest);
let Ok(inside) = source.strip_prefix(&member.dir) else {
continue;
};
let mapped = inside.to_string_lossy().replace('\\', "/");
if let Some(found) = map_to_source(self.fs.as_ref(), &member.dir, &mapped) {
return Some(found);
}
}
map_to_source(self.fs.as_ref(), &member.dir, target)
}
fn member_file(&self, member: &Member, subpath: Option<&str>) -> Option<PathBuf> {
if let Some(exports) = member.manifest.get("exports").filter(|value| !value.is_null()) {
let key = subpath.map_or_else(|| ".".to_owned(), |sub| format!("./{sub}"));
let target = resolve_exports(exports, &key, &self.conditions)?;
return self.map_member_target(member, &target);
}
match subpath {
None => ENTRY_FIELDS
.iter()
.filter_map(|field| member.manifest.get(*field)?.as_str())
.find_map(|target| self.map_member_target(member, target))
.or_else(|| {
self.script
.plain
.resolve(&member.dir, ".")
.ok()
.map(|resolution| resolution.path().to_path_buf())
})
.or_else(|| map_to_source(self.fs.as_ref(), &member.dir, "src/index")),
Some(sub) => self
.script
.plain
.resolve(&member.dir, &format!("./{sub}"))
.ok()
.map(|resolution| resolution.path().to_path_buf())
.or_else(|| map_to_source(self.fs.as_ref(), &member.dir, sub))
.or_else(|| map_to_source(self.fs.as_ref(), &member.dir, &format!("src/{sub}"))),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use std::path::Path;
use skif_config::Config;
use skif_core::MemoryFs;
use super::*;
use crate::input::{BuildDir, InputRequest, InputSource, PathAlias, WorkspaceInputs};
struct RootInputs(WorkspaceInputs);
impl InputSource for RootInputs {
fn inputs(&self, request: &InputRequest<'_>) -> WorkspaceInputs {
if request.package.is_root {
self.0.clone()
} else {
WorkspaceInputs::default()
}
}
}
fn resolver(files: &[(&str, &str)]) -> ModuleResolver {
resolver_with(files, WorkspaceInputs::default())
}
fn resolver_with(files: &[(&str, &str)], inputs: WorkspaceInputs) -> ModuleResolver {
resolver_at(Path::new("/ws"), files, inputs)
}
fn resolver_at(root: &Path, files: &[(&str, &str)], inputs: WorkspaceInputs) -> ModuleResolver {
let fs = files
.iter()
.fold(MemoryFs::new(), |fs, (path, text)| fs.with_file(root.join(path), text));
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(root, &config, &fs).unwrap();
let fs: Arc<dyn FileSystem> = Arc::new(fs);
let sources: Vec<Arc<dyn InputSource>> = vec![Arc::new(RootInputs(inputs))];
let setup = Setup::new(&workspace, &config, &fs, &sources, true);
ModuleResolver::new(fs, &workspace, &setup)
}
fn alias(find: &str, target: Option<&str>) -> PathAlias {
PathAlias {
find: find.to_owned(),
target: target.map(str::to_owned),
}
}
const ROOT: &str = r#"{"name":"root","private":true,"workspaces":["packages/*"]}"#;
#[test]
fn relative_extensions_and_ts_alias() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
("packages/a/src/a.ts", ""),
("packages/a/src/b.tsx", ""),
]);
let importer = Path::new("/ws/packages/a/src/a.ts");
assert_eq!(
r.resolve(importer, "./b").target,
Resolved::File(PathBuf::from("/ws/packages/a/src/b.tsx"))
);
assert_eq!(
r.resolve(importer, "./b.js").target,
Resolved::File(PathBuf::from("/ws/packages/a/src/b.tsx"))
);
assert_eq!(r.resolve(importer, "./missing").target, Resolved::Unresolved);
}
#[test]
fn builtins_externals_and_schemes() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
]);
let importer = Path::new("/ws/packages/a/src/a.ts");
assert_eq!(r.resolve(importer, "node:fs").target, Resolved::Builtin);
assert_eq!(r.resolve(importer, "path").target, Resolved::Builtin);
assert_eq!(r.resolve(importer, "https://a.dev/x.js").target, Resolved::Ignored);
let lodash = r.resolve(importer, "lodash/fp");
assert_eq!(lodash.target, Resolved::External("lodash".to_owned()));
assert_eq!(lodash.package.as_deref(), Some("lodash"));
}
#[test]
fn bin_links_are_installed_code_whether_or_not_installed() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
("packages/a/node_modules/.bin/installed", "#!/bin/sh\n"),
]);
let importer = Path::new("/ws/packages/a/src/a.ts");
for specifier in [
".bin/installed",
".bin/jscodeshift",
"node_modules/.bin/tsc",
"./node_modules/.bin/tsc",
] {
let outcome = r.resolve(importer, specifier);
assert_eq!(outcome.target, Resolved::Asset, "{specifier}");
assert_eq!(outcome.package, None, "{specifier}");
}
}
#[test]
fn workspace_sibling_through_exports_and_dist_mapping() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
(
"packages/b/package.json",
r#"{"name":"@x/b","exports":{".":{"import":"./dist/index.js"},"./util":"./dist/util.js"}}"#,
),
("packages/b/src/index.ts", ""),
("packages/b/src/util.ts", ""),
]);
let importer = Path::new("/ws/packages/a/src/a.ts");
let main = r.resolve(importer, "@x/b");
assert_eq!(
main.target,
Resolved::File(PathBuf::from("/ws/packages/b/src/index.ts"))
);
assert_eq!(main.package.as_deref(), Some("@x/b"));
assert_eq!(
r.resolve(importer, "@x/b/util").target,
Resolved::File(PathBuf::from("/ws/packages/b/src/util.ts"))
);
assert_eq!(
r.resolve(importer, "@x/b/hidden").target,
Resolved::External("@x/b".to_owned())
);
}
#[test]
fn tsconfig_paths() {
let root = Path::new(if cfg!(windows) { "C:/ws" } else { "/ws" });
let r = resolver_at(
root,
&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
(
"packages/a/tsconfig.json",
r#"{"compilerOptions":{"baseUrl":".","paths":{"@app/*":["src/lib/*"]}}}"#,
),
("packages/a/src/a.ts", ""),
("packages/a/src/lib/thing.ts", ""),
],
WorkspaceInputs::default(),
);
let importer = root.join("packages/a/src/a.ts");
assert_eq!(
r.resolve(&importer, "@app/thing").target,
Resolved::File(root.join("packages/a/src/lib/thing.ts"))
);
}
#[test]
fn tsconfig_outside_a_package_applies_to_the_files_below_it() {
let r = resolver(&[
("package.json", ROOT),
(
"packages/tsconfig.json",
r#"{"compilerOptions":{"paths":{"@scope/*":["./*/src/index.ts"]}},"exclude":["**/*.spec.ts"]}"#,
),
("packages/app/src/main.spec.ts", ""),
("packages/app/src/main.js", ""),
("packages/lib/src/index.ts", ""),
("src/legacy/jsconfig.json", r#"{"compilerOptions":{"baseUrl":"."}}"#),
("src/legacy/page.js", ""),
("src/legacy/helpers/util.js", ""),
]);
let lib = Resolved::File(PathBuf::from("/ws/packages/lib/src/index.ts"));
for importer in ["/ws/packages/app/src/main.spec.ts", "/ws/packages/app/src/main.js"] {
assert_eq!(r.resolve(Path::new(importer), "@scope/lib").target, lib, "{importer}");
}
assert_eq!(
r.resolve(Path::new("/ws/src/legacy/page.js"), "helpers/util").target,
Resolved::File(PathBuf::from("/ws/src/legacy/helpers/util.js"))
);
}
#[test]
fn bundler_aliases_resolve_and_unresolved_aliases_are_never_packages() {
let inputs = WorkspaceInputs {
aliases: vec![
alias("@", Some("src")),
alias("@app", Some("src\\lib")),
alias("computed", None),
alias("@gone", Some("nowhere")),
],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
("src/main.ts", ""),
("src/util.ts", ""),
("src/lib/thing.ts", ""),
("src/routes/a/x.ts", ""),
("src/lib/index.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
let file = |path: &str| Resolved::File(PathBuf::from(path));
assert_eq!(r.resolve(importer, "@/util").target, file("/ws/src/util.ts"));
assert_eq!(r.resolve(importer, "@app/thing").target, file("/ws/src/lib/thing.ts"));
assert_eq!(r.resolve(importer, "$lib/thing").target, file("/ws/src/lib/thing.ts"));
assert_eq!(r.resolve(importer, "$lib").target, file("/ws/src/lib/index.ts"));
assert_eq!(r.resolve(importer, "$app/stores").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "computed/x").target, Resolved::Ignored);
let gone = r.resolve(importer, "@gone/x");
assert_eq!(gone.target, Resolved::External("@gone/x".to_owned()));
assert_eq!(gone.package, Some("@gone/x".to_owned()));
let unknown = r.resolve(importer, "@/missing");
assert_eq!(unknown.target, Resolved::Unresolved);
assert_eq!(unknown.package, None);
assert_eq!(
r.resolve(importer, "@scope/real").target,
Resolved::External("@scope/real".to_owned())
);
}
#[test]
fn an_alias_is_an_import_of_the_workspace_package_it_is_named_after_only_inside_that_package() {
let inputs = WorkspaceInputs {
aliases: vec![
alias("ui", Some("src/ui")),
alias("@acme/db/types", Some("generated/db-types")),
alias("@acme/db", Some("packages/db")),
],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
(".gitignore", "packages/db/enums\ngenerated/\n"),
("src/main.ts", ""),
("src/ui/button.ts", ""),
("packages/ui/package.json", r#"{"name":"ui"}"#),
("packages/ui/button.ts", ""),
("packages/db/package.json", r#"{"name":"@acme/db"}"#),
("packages/db/client.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
let local = r.resolve(importer, "ui/button");
assert_eq!(local.target, Resolved::File(PathBuf::from("/ws/src/ui/button.ts")));
assert_eq!(
local.package, None,
"an alias into the importer's own sources is not the member `ui`"
);
let client = r.resolve(importer, "@acme/db/client");
assert_eq!(
client.target,
Resolved::File(PathBuf::from("/ws/packages/db/client.ts"))
);
assert_eq!(client.package, Some("@acme/db".to_owned()));
let enums = r.resolve(importer, "@acme/db/enums");
assert_eq!(enums.target, Resolved::Ignored);
assert_eq!(enums.package, Some("@acme/db".to_owned()));
let types = r.resolve(importer, "@acme/db/types");
assert_eq!(types.target, Resolved::Ignored);
assert_eq!(
types.package, None,
"generated code outside the member is not the member"
);
}
#[test]
fn a_tsconfig_mapping_into_own_sources_is_not_the_member_of_that_name() {
let r = resolver(&[
("package.json", ROOT),
("tsconfig.json", r#"{"compilerOptions":{"strict":true}}"#),
("packages/app/package.json", r#"{"name":"app","private":true}"#),
(
"packages/app/tsconfig.json",
r#"{"extends":"../../tsconfig.json","compilerOptions":{"paths":{"src/*":["./src/*"]}}}"#,
),
("packages/app/src/main.ts", ""),
("packages/app/src/Feature/X.ts", ""),
("packages/other/package.json", r#"{"name":"src","private":true}"#),
("packages/other/index.ts", ""),
]);
let mapped = r.resolve(Path::new("/ws/packages/app/src/main.ts"), "src/Feature/X");
assert_eq!(
mapped.target,
Resolved::File(PathBuf::from("/ws/packages/app/src/Feature/X.ts"))
);
assert_eq!(
mapped.package, None,
"a mapping into the importer's own sources is not the member `src`"
);
let member = r.resolve(Path::new("/ws/packages/app/src/main.ts"), "src");
assert_eq!(member.package, Some("src".to_owned()));
}
#[test]
fn unresolvable_specifiers_that_start_like_packages_are_dependencies() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
(
"packages/ui/package.json",
r#"{"name":"@x/ui","main":"./dist/index.js","exports":{".":"./dist/index.js"}}"#,
),
("packages/a/src/a.ts", ""),
("packages/a/src/style.scss", ""),
]);
let importer = Path::new("/ws/packages/a/src/a.ts");
for specifier in ["@x/ui", "@x/ui/dist/missing", "left-pad/lib/x", "pkg/css/index?inline"] {
let outcome = r.resolve(importer, specifier);
assert!(
matches!(outcome.target, Resolved::External(_)),
"{specifier}: {outcome:?}"
);
}
let style = r.resolve_style(Path::new("/ws/packages/a/src/style.scss"), "pkg/css/index");
assert_eq!(style.target, Resolved::External("pkg".to_owned()));
assert_eq!(
r.resolve(importer, "https://example.com/x.js").target,
Resolved::Ignored
);
assert_eq!(r.resolve(importer, "@/lib/x").target, Resolved::Unresolved);
assert_eq!(r.resolve(importer, "./missing").target, Resolved::Unresolved);
}
#[test]
fn missing_relative_files_of_foreign_types_and_json_are_not_unresolved() {
let r = resolver(&[("package.json", ROOT), ("src/a.ts", "")]);
let importer = Path::new("/ws/src/a.ts");
for specifier in [
"./data.json",
"./theme.css",
"../img/logo.svg",
"./fonts/x.woff2",
"./a.scss",
] {
assert_eq!(r.resolve(importer, specifier).target, Resolved::Ignored, "{specifier}");
}
assert_eq!(r.resolve(importer, "./missing.ts").target, Resolved::Unresolved);
}
#[test]
fn missing_root_relative_files_of_foreign_types_are_not_unresolved() {
let r = resolver(&[("package.json", ROOT), ("src/a.ts", "")]);
let importer = Path::new("/ws/src/a.ts");
for specifier in ["/vite.svg", "/data/x.json"] {
assert_eq!(r.resolve(importer, specifier).target, Resolved::Ignored, "{specifier}");
}
for specifier in ["/content/where", "/src/missing.ts"] {
assert_eq!(
r.resolve(importer, specifier).target,
Resolved::Unresolved,
"{specifier}"
);
}
}
#[test]
fn missing_relative_files_ignored_by_git_are_not_unresolved_through_extension_aliases() {
let r = resolver(&[
("package.json", ROOT),
(".gitignore", "generated/\n*.gen.ts\n"),
("src/a.ts", ""),
]);
let importer = Path::new("/ws/src/a.ts");
let generated = |dir: &str, recursive| Resolved::Generated {
dir: PathBuf::from(dir),
recursive,
};
assert_eq!(
r.resolve(importer, "../generated/x").target,
generated("/ws/generated", true)
);
assert_eq!(r.resolve(importer, "./schema.gen.js").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "./schema.gen.ts").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "./schema.js").target, Resolved::Unresolved);
}
#[test]
fn missing_files_a_gitignore_of_a_directory_below_the_package_lists_are_not_unresolved() {
let r = resolver(&[
("package.json", ROOT),
("src/temp/.gitignore", "*\n!.gitignore\n"),
("src/a.ts", ""),
]);
let importer = Path::new("/ws/src/a.ts");
assert_eq!(
r.resolve(importer, "./temp/config").target,
Resolved::Generated {
dir: PathBuf::from("/ws/src/temp"),
recursive: false
}
);
assert_eq!(r.resolve(importer, "./other/config").target, Resolved::Unresolved);
}
#[test]
fn missing_files_a_gitignore_above_the_root_lists_are_not_unresolved_up_to_the_repository_root() {
let fs = MemoryFs::new()
.with_file("/.gitignore", "*.ts\n")
.with_file("/repo/.git/HEAD", "")
.with_file("/repo/frontend/.gitignore", ".next\n")
.with_file("/repo/frontend/app/package.json", r#"{"name":"app","private":true}"#)
.with_file("/repo/frontend/app/next-env.d.ts", "");
let root = Path::new("/repo/frontend/app");
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(root, &config, &fs).unwrap();
let fs: Arc<dyn FileSystem> = Arc::new(fs);
let sources: Vec<Arc<dyn InputSource>> = vec![Arc::new(RootInputs(WorkspaceInputs::default()))];
let setup = Setup::new(&workspace, &config, &fs, &sources, true);
let r = ModuleResolver::new(fs, &workspace, &setup);
let importer = Path::new("/repo/frontend/app/next-env.d.ts");
assert_eq!(
r.resolve(importer, "./.next/types/routes.d.ts").target,
Resolved::Generated {
dir: PathBuf::from("/repo/frontend/app/.next/types"),
recursive: false
}
);
assert_eq!(r.resolve(importer, "./missing.ts").target, Resolved::Unresolved);
}
#[test]
fn missing_extensionless_files_and_directories_ignored_by_git_are_not_unresolved() {
let r = resolver(&[
("package.json", ROOT),
(
".gitignore",
"src/migrations/sqlite/index.ts
src/schema.gen.ts
src/api/client.gen.ts
src/generated/index.ts
generated/index.ts
",
),
("src/a.ts", ""),
("main.ts", ""),
]);
let importer = Path::new("/ws/src/a.ts");
assert_eq!(
r.resolve(importer, "./migrations/sqlite").target,
Resolved::Generated {
dir: PathBuf::from("/ws/src/migrations"),
recursive: true
}
);
assert_eq!(
r.resolve(importer, "./api/client.gen").target,
Resolved::Generated {
dir: PathBuf::from("/ws/src/api"),
recursive: false
}
);
assert_eq!(r.resolve(importer, "./schema.gen").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "./generated").target, Resolved::Ignored);
assert_eq!(
r.resolve(Path::new("/ws/main.ts"), "./generated").target,
Resolved::Ignored
);
assert_eq!(r.resolve(importer, "../generated").target, Resolved::Ignored);
assert_eq!(
r.resolve(importer, "./migrations/postgres").target,
Resolved::Unresolved
);
}
#[test]
fn windows_separators_join_like_posix_ones() {
let base = Path::new("/ws/pkg");
assert_eq!(
join_normalized(base, "src\\lib\\..\\app/./x"),
Path::new("/ws/pkg/src/app/x")
);
assert_eq!(join_normalized(base, "..\\other"), Path::new("/ws/other"));
assert_eq!(join_normalized(base, ""), base);
}
#[test]
fn out_dir_root_dir_and_source_condition_map_to_sources() {
let inputs = WorkspaceInputs {
build_dirs: vec![BuildDir {
out_dir: "packages/b/build".to_owned(),
root_dir: "packages/b/lib".to_owned(),
}],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
(
"packages/b/package.json",
r#"{"name":"@x/b","main":"./build/index.js","exports":{"./util":{"source":"./lib/util.ts","default":"./build/util.js"}}}"#,
),
("packages/b/lib/index.ts", ""),
("packages/b/lib/util.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/packages/a/src/a.ts");
assert_eq!(
r.resolve(importer, "@x/b/util").target,
Resolved::File(PathBuf::from("/ws/packages/b/lib/util.ts"))
);
}
#[test]
fn relative_imports_cross_root_dirs() {
let r = resolver(&[
("package.json", ROOT),
(
"packages/a/tsconfig.json",
r#"{"compilerOptions":{"rootDirs":["src","bazel-out/bin/src"]}}"#,
),
("packages/a/src/ui/view.ts", ""),
("packages/a/bazel-out/bin/src/ui/generated.ts", ""),
]);
let importer = Path::new("/ws/packages/a/src/ui/view.ts");
let generated = r.resolve(importer, "./generated");
assert_eq!(
generated.target,
Resolved::File(PathBuf::from("/ws/packages/a/bazel-out/bin/src/ui/generated.ts"))
);
assert_eq!(
generated.root_dirs,
Some(PathBuf::from("/ws/packages/a/bazel-out/bin/src"))
);
assert_eq!(r.resolve(importer, "./view").root_dirs, None);
assert_eq!(r.resolve(importer, "./absent").target, Resolved::Unresolved);
}
#[test]
fn another_root_dir_names_the_root_a_resolved_file_lies_below() {
let r = resolver(&[
("package.json", ROOT),
(
"packages/a/tsconfig.json",
r#"{"compilerOptions":{"rootDirs":["src","bazel-out/bin/src"]}}"#,
),
("packages/a/src/ui/view.ts", ""),
("packages/a/src/ui/local.ts", ""),
("packages/a/bazel-out/bin/src/ui/generated.d.ts", ""),
("packages/a/bazel-out/bin/src/shared/util.js", ""),
("packages/a/other/stray.ts", ""),
]);
let importer = Path::new("/ws/packages/a/src/ui/view.ts");
let output = Some(PathBuf::from("/ws/packages/a/bazel-out/bin/src"));
let cases = [
(
"./generated.js",
"/ws/packages/a/bazel-out/bin/src/ui/generated.d.ts",
output.clone(),
),
(
"../shared/util",
"/ws/packages/a/bazel-out/bin/src/shared/util.js",
output,
),
("./local", "/ws/packages/a/src/ui/local.ts", None),
("../../other/stray", "/ws/packages/a/other/stray.ts", None),
("./stray", "/ws/packages/a/other/stray.ts", None),
("generated", "/ws/packages/a/bazel-out/bin/src/ui/generated.d.ts", None),
];
for (specifier, found, expected) in cases {
assert_eq!(
r.another_root_dir(importer, specifier, Path::new(found)),
expected,
"{specifier}"
);
}
}
#[test]
fn is_at_matches_the_file_a_relative_path_names() {
let dir = Path::new("/ws/src/ui");
let cases = [
("./a.js", "/ws/src/ui/a.ts", true),
("./a", "/ws/src/ui/a/index.ts", true),
("./a", "/ws/src/ui/a.d.ts", true),
("./schema.js", "/ws/src/ui/schema.d.ts", true),
("./types.mjs", "/ws/src/ui/types.d.mts", true),
("./a.b", "/ws/src/ui/a.b.ts", true),
("./a.b", "/ws/src/ui/a.ts", false),
("./a", "/ws/src/ui/b.ts", false),
("../a.js", "/ws/src/a.ts", true),
("../a.js", "/ws/src/ui/a.ts", false),
];
for (specifier, found, expected) in cases {
assert_eq!(
is_at(Path::new(found), &join_normalized(dir, specifier)),
expected,
"{specifier} -> {found}"
);
}
}
#[test]
fn relative_imports_into_an_ignored_root_dir_are_generated_types() {
let r = resolver(&[
("package.json", r#"{"name":"app","private":true}"#),
(".gitignore", "/node_modules/\n/.react-router/\n"),
(
"tsconfig.json",
r#"{"compilerOptions":{"rootDirs":[".","./.react-router/types"]}}"#,
),
("app/root.tsx", ""),
]);
let importer = Path::new("/ws/app/root.tsx");
assert_eq!(r.resolve(importer, "./+types/root").target, Resolved::Ignored);
let r = resolver(&[
("package.json", r#"{"name":"app","private":true}"#),
(".gitignore", "/node_modules/\n/.react-router/\n"),
(
"tsconfig.json",
r#"{"compilerOptions":{"rootDirs":[".","./.react-router/types"]}}"#,
),
("app/root.tsx", ""),
(".react-router/types/app/+types/root.ts", ""),
]);
assert_eq!(
r.resolve(importer, "./+types/root").target,
Resolved::File(PathBuf::from("/ws/.react-router/types/app/+types/root.ts"))
);
assert_eq!(r.resolve(importer, "./+types/home").target, Resolved::Unresolved);
}
#[test]
fn sveltekit_route_types_are_unknown_while_the_extended_config_is_not_generated() {
let files = [
("package.json", r#"{"name":"app","private":true}"#),
(".gitignore", "node_modules\n/.svelte-kit\n"),
(
"jsconfig.json",
"{\n \"extends\": \"./.svelte-kit/tsconfig.json\",\n // aliases come from svelte.config.js\n \"compilerOptions\": { \"checkJs\": true }\n}\n",
),
("src/routes/todos/+page.svelte", ""),
];
let r = resolver(&files);
let importer = Path::new("/ws/src/routes/todos/+page.svelte");
for specifier in ["./$types", "./$types.js", "./$types.d.ts"] {
assert_eq!(r.resolve(importer, specifier).target, Resolved::Ignored, "{specifier}");
}
for specifier in ["./$type", "../$types", "./missing"] {
assert_eq!(
r.resolve(importer, specifier).target,
Resolved::Unresolved,
"{specifier}"
);
}
let r = resolver(&[files[0], files[2], files[3]]);
assert_eq!(r.resolve(importer, "./$types").target, Resolved::Unresolved);
let r = resolver(&[
files[0],
files[1],
files[2],
files[3],
(
".svelte-kit/tsconfig.json",
r#"{"compilerOptions":{"rootDirs":["..","./types"]}}"#,
),
(".svelte-kit/types/src/routes/todos/$types.d.ts", ""),
]);
assert_eq!(
r.resolve(importer, "./$types").target,
Resolved::File(PathBuf::from("/ws/.svelte-kit/types/src/routes/todos/$types.d.ts"))
);
}
#[test]
fn sveltekit_route_types_are_unknown_while_a_config_extended_through_another_is_not_generated() {
let files = [
("package.json", r#"{"name":"app","private":true}"#),
(".gitignore", "node_modules\n/.svelte-kit\n"),
("tsconfig.json", r#"{"extends":"./.svelte-kit/tsconfig.json"}"#),
(
"src/routes/admin/tsconfig.json",
r#"{"extends":"../../../tsconfig.json","compilerOptions":{"strict":true}}"#,
),
("src/routes/admin/+page.svelte", ""),
];
let importer = Path::new("/ws/src/routes/admin/+page.svelte");
let r = resolver(&files);
assert_eq!(r.resolve(importer, "./$types").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "./missing").target, Resolved::Unresolved);
let r = resolver(&[files[0], files[2], files[3], files[4]]);
assert_eq!(r.resolve(importer, "./$types").target, Resolved::Unresolved);
}
#[test]
fn sveltekit_route_types_follow_the_root_dirs_of_the_generated_config() {
let files = [
("package.json", r#"{"name":"app","private":true}"#),
(".gitignore", "node_modules\n/.svelte-kit\n"),
("tsconfig.json", r#"{"extends":"./.svelte-kit/tsconfig.json"}"#),
(
".svelte-kit/tsconfig.json",
r#"{"compilerOptions":{"rootDirs":["..","./types"]}}"#,
),
("src/routes/todos/+page.svelte", ""),
("src/routes/about/+page.svelte", ""),
];
let todos = Path::new("/ws/src/routes/todos/+page.svelte");
let about = Path::new("/ws/src/routes/about/+page.svelte");
let r = resolver(&files);
assert_eq!(r.resolve(todos, "./$types").target, Resolved::Ignored);
assert_eq!(r.resolve(todos, "./missing").target, Resolved::Ignored);
let r = resolver(&[
files[0],
files[1],
files[2],
files[3],
files[4],
files[5],
(".svelte-kit/types/src/routes/todos/$types.d.ts", ""),
]);
assert_eq!(
r.resolve(todos, "./$types").target,
Resolved::File(PathBuf::from("/ws/.svelte-kit/types/src/routes/todos/$types.d.ts"))
);
assert_eq!(r.resolve(about, "./$types").target, Resolved::Unresolved);
}
#[test]
fn members_are_found_by_their_directory_and_only_there() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a","version":"1.0.0"}"#),
("packages/b/package.json", r#"{"name":"@x/b","version":"2.0.0"}"#),
]);
let version = |dir: &str| {
r.member_at(Path::new(dir))
.map(|member| (member.dir.clone(), member.manifest["version"].clone()))
};
assert_eq!(
version("/ws/packages/a"),
Some((PathBuf::from("/ws/packages/a"), Value::from("1.0.0")))
);
assert_eq!(
version("/ws/packages/b"),
Some((PathBuf::from("/ws/packages/b"), Value::from("2.0.0")))
);
assert_eq!(version("/ws/packages"), None);
assert_eq!(version("/ws/packages/a/src"), None);
assert!(r.member_at(Path::new("/ws")).is_some());
}
#[test]
fn hash_specifiers_are_unresolved_only_where_the_package_declares_imports() {
let r = resolver(&[
("package.json", ROOT),
(
"packages/a/package.json",
r##"{"name":"@x/a","imports":{"#x":"./src/x.js"}}"##,
),
("packages/a/src/a.ts", ""),
("packages/b/package.json", r#"{"name":"@x/b"}"#),
("packages/b/src/b.ts", ""),
(
"packages/b/templates/t/package.json",
r##"{"imports":{"#t":"./t.js"}}"##,
),
("packages/b/templates/t/index.ts", ""),
]);
let a = Path::new("/ws/packages/a/src/a.ts");
let b = Path::new("/ws/packages/b/src/b.ts");
let template = Path::new("/ws/packages/b/templates/t/index.ts");
assert_eq!(r.resolve(a, "#missing").target, Resolved::Unresolved);
assert_eq!(r.resolve(b, "#missing").target, Resolved::Ignored);
assert_eq!(r.resolve(template, "#missing").target, Resolved::Unresolved);
}
#[test]
fn hash_specifiers_mapped_by_tsconfig_paths_or_an_alias_stay_unresolved_without_imports() {
let inputs = WorkspaceInputs {
aliases: vec![alias("#aliased", Some("src/aliased"))],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", r#"{"name":"root","private":true}"#),
(
"tsconfig.json",
r##"{"compilerOptions":{"paths":{"#gen/*":["./src/gen/*"]}}}"##,
),
("src/a.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/src/a.ts");
assert_eq!(r.resolve(importer, "#gen/missing").target, Resolved::Unresolved);
assert_eq!(r.resolve(importer, "#aliased/missing").target, Resolved::Unresolved);
assert_eq!(r.resolve(importer, "#unmapped").target, Resolved::Ignored);
}
#[test]
fn imports_pointing_at_build_output_map_back_to_sources() {
let inputs = WorkspaceInputs {
build_dirs: vec![BuildDir {
out_dir: "packages/b/build".to_owned(),
root_dir: "packages/b/lib".to_owned(),
}],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
(
"packages/a/package.json",
r##"{"name":"@x/a","imports":{"#x":"./dist/x.js","#y/*":"./dist/y/*.js","#z":{"node":"./dist/z.js","default":"./dist/z.alt.js"}}}"##,
),
("packages/a/src/x.ts", ""),
("packages/a/src/y/deep.ts", ""),
("packages/a/src/z.ts", ""),
("packages/a/src/a.ts", ""),
(
"packages/b/package.json",
r##"{"name":"@x/b","imports":{"#util":"./build/util.mjs"}}"##,
),
("packages/b/lib/util.ts", ""),
("packages/b/lib/b.ts", ""),
],
inputs,
);
let a = Path::new("/ws/packages/a/src/a.ts");
for (specifier, source) in [("#x", "x.ts"), ("#y/deep", "y/deep.ts"), ("#z", "z.ts")] {
assert_eq!(
r.resolve(a, specifier).target,
Resolved::File(PathBuf::from(format!("/ws/packages/a/src/{source}"))),
"{specifier}"
);
}
assert_eq!(
r.resolve(Path::new("/ws/packages/b/lib/b.ts"), "#util").target,
Resolved::File(PathBuf::from("/ws/packages/b/lib/util.ts"))
);
assert_eq!(r.resolve(a, "#missing").target, Resolved::Unresolved);
}
#[test]
fn node_modules_specifiers_are_uses_of_the_package_after_the_last_segment() {
let r = resolver(&[("package.json", ROOT), ("src/main.ts", "")]);
let importer = Path::new("/ws/src/main.ts");
for (specifier, package) in [
("mylib/node_modules/express", "express"),
("../node_modules/lodash/fp", "lodash"),
("/abs/node_modules/@scope/pkg/x", "@scope/pkg"),
] {
let outcome = r.resolve(importer, specifier);
assert_eq!(outcome.target, Resolved::External(package.to_owned()), "{specifier}");
assert_eq!(outcome.package.as_deref(), Some(package), "{specifier}");
}
}
#[test]
fn bare_specifiers_resolve_through_lookup_directories_in_order() {
let inputs = WorkspaceInputs {
module_dirs: vec![
"shared".to_owned(),
".".to_owned(),
"node_modules".to_owned(),
"src".to_owned(),
],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
("src/main.ts", ""),
("src/services/api.ts", ""),
("src/views/home.ts", ""),
("config.js", ""),
("shared/services/api.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
let file = |path: &str| Resolved::File(PathBuf::from(path));
assert_eq!(
r.resolve(importer, "services/api").target,
file("/ws/shared/services/api.ts")
);
assert_eq!(r.resolve(importer, "views/home").target, file("/ws/src/views/home.ts"));
assert_eq!(r.resolve(importer, "config").target, file("/ws/config.js"));
assert_eq!(
r.resolve(importer, "react").target,
Resolved::External("react".to_owned())
);
assert_eq!(r.resolve(importer, "missing/thing").package.as_deref(), Some("missing"));
}
#[test]
fn alias_to_a_package_directory_with_unbuilt_output_is_not_unresolved() {
let inputs = WorkspaceInputs {
aliases: vec![
alias("@kit", Some("kit")),
alias("@bare", Some("bare")),
alias("@gone", Some("kit/nowhere")),
],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
("src/main.ts", ""),
("kit/package.json", r#"{"name":"kit","main":"dist/index.js"}"#),
("kit/src/index.ts", ""),
("kit/src/Button.tsx", ""),
("bare/package.json", r#"{"name":"bare","main":"dist/index.js"}"#),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
let file = |path: &str| Resolved::File(PathBuf::from(path));
assert_eq!(r.resolve(importer, "@kit").target, file("/ws/kit/src/index.ts"));
assert_eq!(
r.resolve(importer, "@kit/Button").target,
file("/ws/kit/src/Button.tsx")
);
assert_eq!(r.resolve(importer, "@bare").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "@gone").target, Resolved::Unresolved);
assert_eq!(
r.resolve(importer, "@kit/nothing").target,
Resolved::External("@kit/nothing".to_owned())
);
}
#[test]
fn a_relative_package_directory_with_unbuilt_output_maps_to_source() {
let r = resolver(&[
("package.json", ROOT),
(
"packages/cli/package.json",
r#"{"name":"cli","main":"./build/index.js","bin":{"cli":"./bin/cli.js"}}"#,
),
("packages/cli/bin/cli.js", ""),
("packages/cli/src/index.ts", ""),
(
"test/automation/package.json",
r#"{"name":"automation","main":"out/index.js"}"#,
),
("test/automation/src/index.ts", ""),
("test/smoke/src/main.ts", ""),
("test/bare/package.json", r#"{"name":"bare","main":"out/index.js"}"#),
]);
assert_eq!(
r.resolve(Path::new("/ws/packages/cli/bin/cli.js"), "..").target,
file("/ws/packages/cli/src/index.ts")
);
let smoke = Path::new("/ws/test/smoke/src/main.ts");
assert_eq!(
r.resolve(smoke, "../../automation").target,
file("/ws/test/automation/src/index.ts")
);
assert_eq!(r.resolve(smoke, "../../bare").target, Resolved::Ignored);
assert_eq!(r.resolve(smoke, "../../missing").target, Resolved::Unresolved);
}
fn file(path: &str) -> Resolved {
Resolved::File(PathBuf::from(path))
}
#[test]
fn declaration_files_only_stand_in_for_missing_runtime_files() {
let r = resolver(&[
("package.json", ROOT),
("src/main.js", ""),
("src/Card.js", ""),
("src/Card.d.ts", ""),
("src/Types.d.ts", ""),
("src/Typed.ts", ""),
("src/Typed.d.ts", ""),
]);
let importer = Path::new("/ws/src/main.js");
assert_eq!(r.resolve(importer, "./Card").target, file("/ws/src/Card.js"));
assert_eq!(r.resolve(importer, "./Card.js").target, file("/ws/src/Card.js"));
assert_eq!(r.resolve(importer, "./Types").target, file("/ws/src/Types.d.ts"));
assert_eq!(r.resolve(importer, "./Typed").target, file("/ws/src/Typed.ts"));
}
#[test]
fn lookup_directories_beat_installed_packages() {
let inputs = WorkspaceInputs {
module_dirs: vec!["src".to_owned()],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
("src/main.ts", ""),
("src/utils/index.ts", ""),
(
"node_modules/utils/package.json",
r#"{"name":"utils","main":"index.js"}"#,
),
("node_modules/utils/index.js", ""),
(
"node_modules/react/package.json",
r#"{"name":"react","main":"index.js"}"#,
),
("node_modules/react/index.js", ""),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
let local = r.resolve(importer, "utils");
assert_eq!(local.target, file("/ws/src/utils/index.ts"));
assert_eq!(local.package, None);
assert_eq!(r.resolve(importer, "react").package.as_deref(), Some("react"));
}
struct KindCounts {
inner: MemoryFs,
kinds: std::sync::Mutex<HashMap<PathBuf, usize>>,
}
impl FileSystem for KindCounts {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
self.inner.read_to_string(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<skif_core::DirEntry>> {
self.inner.read_dir(path)
}
fn is_file(&self, path: &Path) -> bool {
self.kind(path).is_file
}
fn is_dir(&self, path: &Path) -> bool {
self.kind(path).is_dir
}
fn kind(&self, path: &Path) -> skif_core::PathKind {
*self.kinds.lock().unwrap().entry(path.to_path_buf()).or_default() += 1;
self.inner.kind(path)
}
}
#[test]
fn a_memo_computes_each_key_once_and_keeps_misses() {
let memo: Memo<PathBuf, Option<usize>> = Memo::new();
let computed = std::cell::Cell::new(0);
let compute = |value: Option<usize>| {
computed.set(computed.get() + 1);
value
};
for _ in 0..3 {
assert_eq!(memo.get_or_compute(Path::new("/ws/a"), || compute(Some(1))), Some(1));
assert_eq!(memo.get_or_compute(Path::new("/ws/b"), || compute(None)), None);
}
assert_eq!(computed.get(), 2);
assert_eq!(memo.get(Path::new("/ws/a")), Some(Some(1)));
assert_eq!(memo.get(Path::new("/ws/c")), None);
}
#[test]
fn a_pair_memo_tells_pairs_apart_by_both_keys() {
let memo: PairMemo<String, usize> = PairMemo::new();
assert_eq!(memo.get_or_compute("a", "./x", || 1), 1);
assert_eq!(memo.get_or_compute("a", "./y", || 2), 2);
assert_eq!(memo.get_or_compute("b", "./x", || 3), 3);
assert_eq!(memo.get_or_compute("a", "./x", || 4), 1);
assert_eq!(memo.get_or_compute("b", "./x", || 5), 3);
}
#[test]
fn the_files_of_a_directory_share_its_scope_chain() {
let r = resolver(&[
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
("packages/a/src/x.ts", ""),
("packages/a/src/y.ts", ""),
]);
let dirs = |path: &str| -> Vec<PathBuf> {
r.scopes_of(Path::new(path))
.map(|(_, scope)| scope.dir.clone())
.collect()
};
let both = vec![PathBuf::from("/ws/packages/a"), PathBuf::from("/ws")];
assert_eq!(dirs("/ws/packages/a/src/x.ts"), both);
assert_eq!(dirs("/ws/packages/a/src/y.ts"), both);
assert_eq!(dirs("/ws/packages/a/src"), both);
assert_eq!(dirs("/ws/packages/a"), both);
assert_eq!(dirs("/ws/packages"), vec![PathBuf::from("/ws")]);
assert_eq!(dirs("/ws/packages/b.ts"), vec![PathBuf::from("/ws")]);
let held: usize = r.scope_chains.0.iter().map(|shard| shard.read().unwrap().len()).sum();
assert_eq!(held, 4);
}
#[test]
fn stylesheet_forms_of_a_directory_specifier_have_no_double_slash() {
assert_eq!(
style_forms("bootstrap/scss/"),
["bootstrap/scss/", "bootstrap/_scss", "bootstrap/scss/_index"]
);
assert_eq!(style_forms("theme"), ["theme", "_theme", "theme/_index"]);
}
#[test]
fn every_set_of_resolvers_shares_one_lookup_of_a_path() {
let root = Path::new("/ws");
let files = [
("package.json", ROOT),
("src/main.js", ""),
("src/main.scss", ""),
("src/widget.page", ""),
];
let memory = files
.iter()
.fold(MemoryFs::new(), |fs, (path, text)| fs.with_file(root.join(path), text));
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(root, &config, &memory).unwrap();
let counting = Arc::new(KindCounts {
inner: memory,
kinds: std::sync::Mutex::default(),
});
let fs: Arc<dyn FileSystem> = Arc::clone(&counting) as Arc<dyn FileSystem>;
let inputs = WorkspaceInputs {
extensions: vec![".page".to_owned()],
..WorkspaceInputs::default()
};
let sources: Vec<Arc<dyn InputSource>> = vec![Arc::new(RootInputs(inputs))];
let setup = Setup::new(&workspace, &config, &fs, &sources, true);
let r = ModuleResolver::new(fs, &workspace, &setup);
assert_eq!(r.script_with_extensions.len(), 1);
let with_extensions = r.script_with_extensions.values().next().unwrap();
let importer = Path::new("/ws/src/main.js");
for resolvers in [&r.script, with_extensions, &r.style] {
let _ = resolvers.attempt(importer, "./widget");
}
let kinds = counting.kinds.lock().unwrap();
assert!(kinds.len() > 3, "the resolvers looked at {kinds:?}");
let repeated: Vec<_> = kinds.iter().filter(|(_, count)| **count > 1).collect();
assert_eq!(repeated, Vec::<(&PathBuf, &usize)>::new());
}
struct TsconfigReads {
inner: MemoryFs,
reads: std::sync::atomic::AtomicUsize,
}
impl FileSystem for TsconfigReads {
fn read_to_string(&self, path: &Path) -> io::Result<Arc<str>> {
if path.file_name().is_some_and(|name| name == "tsconfig.json") {
self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
self.inner.read_to_string(path)
}
fn read_dir(&self, path: &Path) -> io::Result<Vec<skif_core::DirEntry>> {
self.inner.read_dir(path)
}
fn is_file(&self, path: &Path) -> bool {
self.inner.is_file(path)
}
fn is_dir(&self, path: &Path) -> bool {
self.inner.is_dir(path)
}
}
#[test]
fn a_tsconfig_that_cannot_be_loaded_is_read_once_for_every_importer_below_it() {
let root = Path::new(if cfg!(windows) { "C:/ws" } else { "/ws" });
let files = [
("package.json", ROOT),
("packages/a/package.json", r#"{"name":"@x/a"}"#),
("packages/a/tsconfig.json", r#"{"extends":"@x/tsconfig/base.json"}"#),
("packages/a/src/a.ts", ""),
("packages/a/src/b.ts", ""),
("packages/a/src/c.ts", ""),
("packages/a/src/deep/d.ts", ""),
];
let memory = files
.iter()
.fold(MemoryFs::new(), |fs, (path, text)| fs.with_file(root.join(path), text));
let config = Config::from_toml_str("").unwrap();
let workspace = Workspace::discover_with_fs(root, &config, &memory).unwrap();
let counting = Arc::new(TsconfigReads {
inner: memory,
reads: std::sync::atomic::AtomicUsize::new(0),
});
let fs: Arc<dyn FileSystem> = Arc::clone(&counting) as Arc<dyn FileSystem>;
let sources: Vec<Arc<dyn InputSource>> = vec![Arc::new(RootInputs(WorkspaceInputs::default()))];
let setup = Setup::new(&workspace, &config, &fs, &sources, true);
let r = ModuleResolver::new(fs, &workspace, &setup);
let before = counting.reads.load(std::sync::atomic::Ordering::SeqCst);
let src = root.join("packages/a/src");
for (importer, specifier, found) in [
("a.ts", "./b", "b.ts"),
("a.ts", "./c.js", "c.ts"),
("b.ts", "./c", "c.ts"),
("c.ts", "./deep/d", "deep/d.ts"),
("deep/d.ts", "../a", "a.ts"),
] {
assert_eq!(
r.resolve(&src.join(importer), specifier).target,
Resolved::File(src.join(found)),
"{specifier} from {importer}"
);
}
let reads = counting.reads.load(std::sync::atomic::Ordering::SeqCst) - before;
assert_eq!(reads, 2, "the tsconfig was read {reads} times");
}
#[test]
fn bundler_extensions_let_imports_omit_them() {
let files = [("package.json", ROOT), ("src/main.js", ""), ("src/widget.page", "")];
let importer = Path::new("/ws/src/main.js");
assert_eq!(
resolver(&files).resolve(importer, "./widget").target,
Resolved::Unresolved
);
let inputs = WorkspaceInputs {
extensions: vec![".page".to_owned()],
..WorkspaceInputs::default()
};
assert_eq!(
resolver_with(&files, inputs).resolve(importer, "./widget").target,
Resolved::Asset
);
}
#[test]
fn alias_into_node_modules_is_the_package_even_when_not_installed() {
let inputs = WorkspaceInputs {
aliases: vec![alias("svelte", Some("node_modules/svelte"))],
..WorkspaceInputs::default()
};
let r = resolver_with(&[("package.json", ROOT), ("src/main.js", "")], inputs);
let outcome = r.resolve(Path::new("/ws/src/main.js"), "svelte");
assert_eq!(outcome.target, Resolved::External("svelte".to_owned()));
assert_eq!(outcome.package.as_deref(), Some("svelte"));
let nested = r.resolve(Path::new("/ws/src/main.js"), "svelte/store");
assert_eq!(nested.package.as_deref(), Some("svelte"));
}
#[test]
fn root_relative_stylesheets_resolve_from_the_package_root() {
let r = resolver(&[
("package.json", ROOT),
("src/styles/main.css", ""),
("src/styles/reset.css", ""),
("public/fonts.css", ""),
]);
let importer = Path::new("/ws/src/styles/main.css");
assert_eq!(
r.resolve_style(importer, "/src/styles/reset.css").target,
file("/ws/src/styles/reset.css")
);
assert_eq!(
r.resolve_style(importer, "/fonts.css").target,
file("/ws/public/fonts.css")
);
assert_eq!(
r.resolve_style(importer, "/served/by/server.css").target,
Resolved::Ignored
);
assert_eq!(r.resolve_style(importer, "./missing.css").target, Resolved::Unresolved);
}
#[test]
fn dollar_aliases_resolve_in_stylesheets() {
let r = resolver(&[
("package.json", ROOT),
("src/lib/theme.css", ""),
("src/routes/page.css", ""),
]);
let importer = Path::new("/ws/src/routes/page.css");
assert_eq!(
r.resolve_style(importer, "$lib/theme.css").target,
file("/ws/src/lib/theme.css")
);
}
#[test]
fn ignored_targets_are_unknown_rather_than_unresolved() {
let inputs = WorkspaceInputs {
aliases: vec![alias("@gen", Some("generated"))],
..WorkspaceInputs::default()
};
let r = resolver_with(
&[
("package.json", ROOT),
(".gitignore", "src/consts/\ngenerated\n!generated/kept.ts\n"),
("src/main.ts", ""),
],
inputs,
);
let importer = Path::new("/ws/src/main.ts");
assert_eq!(
r.resolve(importer, "./consts/index").target,
Resolved::Generated {
dir: PathBuf::from("/ws/src/consts"),
recursive: true
}
);
assert_eq!(r.resolve(importer, "@gen/x").target, Resolved::Ignored);
assert_eq!(r.resolve(importer, "./other/index").target, Resolved::Unresolved);
}
#[test]
fn bazel_workspace_name_prefixes_root_relative_requires() {
for (marker, text) in [
(
"MODULE.bazel",
"module(\n name = \"mono\",\n version = \"1.0\",\n)\n",
),
("WORKSPACE", "workspace(name = 'mono')\n"),
] {
let r = resolver(&[
("package.json", ROOT),
(marker, text),
("src/main.js", ""),
("lib/util.js", ""),
]);
let importer = Path::new("/ws/src/main.js");
let found = r.resolve(importer, "mono/lib/util");
assert_eq!(found.target, file("/ws/lib/util.js"), "{marker}");
assert_eq!(found.package, None, "{marker}");
assert_eq!(
r.resolve(importer, "mono/lib/missing").target,
Resolved::External("mono".to_owned()),
"{marker}"
);
assert_eq!(
r.resolve(importer, "other/lib/util").target,
Resolved::External("other".to_owned()),
"{marker}"
);
}
}
}