use crate::model::ModuleId;
use rustc_hash::{FxHashMap, FxHashSet};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathAlias {
pub scope: PathBuf,
pub prefix: String,
pub wildcard: bool,
pub targets: Vec<PathBuf>,
}
impl PathAlias {
pub fn apply(&self, specifier: &str) -> Vec<PathBuf> {
let Some(suffix) = self.suffix_of(specifier) else {
return Vec::new();
};
self.targets
.iter()
.map(|target| match suffix.is_empty() {
true => target.clone(),
false => normalize(&target.join(suffix)),
})
.collect()
}
fn suffix_of<'a>(&self, specifier: &'a str) -> Option<&'a str> {
match self.wildcard {
true => specifier
.strip_prefix(self.prefix.as_str())
.filter(|rest| !rest.is_empty()),
false => (specifier == self.prefix).then_some(""),
}
}
fn reads(&self, specifier: &str, importer: &Path) -> bool {
self.suffix_of(specifier).is_some() && importer.starts_with(&self.scope)
}
fn specificity(&self) -> (usize, usize) {
(self.scope.components().count(), self.prefix.len())
}
}
pub struct ModuleIndex {
by_path: FxHashMap<PathBuf, ModuleId>,
stems: FxHashSet<PathBuf>,
roots: Vec<PathBuf>,
aliases: Vec<PathAlias>,
import_roots: Vec<PathBuf>,
}
impl ModuleIndex {
pub fn new(roots: Vec<PathBuf>) -> Self {
Self {
by_path: FxHashMap::default(),
stems: FxHashSet::default(),
roots,
aliases: Vec::new(),
import_roots: Vec::new(),
}
}
pub fn set_import_roots(&mut self, mut roots: Vec<PathBuf>) {
roots.sort();
roots.dedup();
self.import_roots = roots;
}
pub fn import_roots(&self) -> &[PathBuf] {
&self.import_roots
}
pub fn paths(&self) -> impl Iterator<Item = &Path> {
self.by_path.keys().map(PathBuf::as_path)
}
pub fn set_aliases(&mut self, mut aliases: Vec<PathAlias>) {
aliases.sort_by_key(|alias| std::cmp::Reverse(alias.specificity()));
self.aliases = aliases;
}
pub fn aliases(&self) -> &[PathAlias] {
&self.aliases
}
pub fn insert(&mut self, path: PathBuf, module: ModuleId) {
let mut stem = path.with_extension("");
for _ in 0..2 {
if stem.extension().is_none() {
break;
}
self.stems.insert(stem.clone());
stem = stem.with_extension("");
}
self.stems.insert(stem);
if let Some(directory) = path.parent() {
self.stems.insert(directory.to_path_buf());
}
self.by_path.insert(path, module);
}
pub fn could_name(&self, base: &Path) -> bool {
self.stems.contains(base)
|| (base.extension().is_some() && self.stems.contains(&base.with_extension("")))
}
pub fn get(&self, path: &Path) -> Option<ModuleId> {
self.by_path.get(path).copied()
}
pub fn contains(&self, path: &Path) -> bool {
self.by_path.contains_key(path)
}
pub fn roots(&self) -> &[PathBuf] {
&self.roots
}
pub fn under(&self, directory: &Path, keep: impl Fn(&Path) -> bool) -> Vec<ModuleId> {
let mut found: Vec<(&PathBuf, ModuleId)> = self
.by_path
.iter()
.filter(|(path, _)| path.strip_prefix(directory).is_ok_and(&keep))
.map(|(path, id)| (path, *id))
.collect();
found.sort_unstable();
found.into_iter().map(|(_, id)| id).collect()
}
pub fn against_roots(
&self,
relative: &str,
try_at: impl Fn(&Path) -> Option<ModuleId>,
) -> Option<ModuleId> {
self.roots
.iter()
.find_map(|root| try_at(&normalize(&root.join(relative))))
}
pub fn against_aliases(
&self,
specifier: &str,
importer: &Path,
try_at: impl Fn(&Path) -> Option<ModuleId>,
) -> Option<ModuleId> {
self.aliases
.iter()
.filter(|alias| alias.reads(specifier, importer))
.flat_map(|alias| alias.apply(specifier))
.find_map(|candidate| try_at(&candidate))
}
pub fn claims(&self, specifier: &str, importer: &Path) -> bool {
self.aliases
.iter()
.any(|alias| !alias.prefix.is_empty() && alias.reads(specifier, importer))
}
pub fn len(&self) -> usize {
self.by_path.len()
}
pub fn is_empty(&self) -> bool {
self.by_path.is_empty()
}
}
pub fn append_extension(path: &Path, extension: &str) -> PathBuf {
let mut name = path
.file_name()
.map(|n| n.to_os_string())
.unwrap_or_default();
name.push(".");
name.push(extension);
path.with_file_name(name)
}
pub fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push("..");
}
}
other => out.push(other.as_os_str()),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_index_answers_exact_paths_only() {
let mut index = ModuleIndex::new(vec![PathBuf::from("/p")]);
index.insert(PathBuf::from("/p/src/a.ts"), ModuleId(3));
assert_eq!(index.get(Path::new("/p/src/a.ts")), Some(ModuleId(3)));
assert!(index.contains(Path::new("/p/src/a.ts")));
assert_eq!(index.get(Path::new("/p/src/a")), None);
assert_eq!(index.len(), 1);
assert!(!index.is_empty());
}
#[test]
fn against_roots_tries_each_root_in_order() {
let mut index = ModuleIndex::new(vec![PathBuf::from("/first"), PathBuf::from("/second")]);
index.insert(PathBuf::from("/second/lib/x.ts"), ModuleId(1));
let found = index.against_roots("lib/x.ts", |candidate| index.get(candidate));
assert_eq!(found, Some(ModuleId(1)));
assert_eq!(
index.against_roots("lib/missing.ts", |candidate| index.get(candidate)),
None
);
}
fn alias(scope: &str, prefix: &str, wildcard: bool, targets: &[&str]) -> PathAlias {
PathAlias {
scope: PathBuf::from(scope),
prefix: prefix.to_string(),
wildcard,
targets: targets.iter().map(PathBuf::from).collect(),
}
}
#[test]
fn a_wildcard_alias_joins_the_matched_suffix_to_each_target() {
let a = alias("/p", "@/", true, &["/p/src", "/p/lib"]);
assert_eq!(
a.apply("@/ui/button"),
vec![
PathBuf::from("/p/src/ui/button"),
PathBuf::from("/p/lib/ui/button")
]
);
assert!(
a.apply("@").is_empty(),
"a wildcard needs something to match"
);
assert!(a.apply("other/x").is_empty());
}
#[test]
fn an_exact_alias_matches_the_whole_specifier_only() {
let a = alias("/p", "@config", false, &["/p/src/config.ts"]);
assert_eq!(a.apply("@config"), vec![PathBuf::from("/p/src/config.ts")]);
assert!(a.apply("@config/deep").is_empty());
}
#[test]
fn aliases_are_tried_most_specific_first() {
let mut index = ModuleIndex::new(vec![PathBuf::from("/p")]);
index.insert(PathBuf::from("/p/narrow/button.ts"), ModuleId(1));
index.insert(PathBuf::from("/p/wide/ui/button.ts"), ModuleId(2));
index.set_aliases(vec![
alias("/p", "@/", true, &["/p/wide"]),
alias("/p", "@/ui/", true, &["/p/narrow"]),
]);
assert_eq!(index.aliases()[0].prefix, "@/ui/", "longer prefix first");
let found = index.against_aliases("@/ui/button", Path::new("/p/a.ts"), |candidate| {
index.get(&append_extension(candidate, "ts"))
});
assert_eq!(found, Some(ModuleId(1)));
}
#[test]
fn an_alias_outside_the_importers_directory_is_skipped() {
let mut index = ModuleIndex::new(vec![PathBuf::from("/p")]);
index.insert(PathBuf::from("/p/one/src/x.ts"), ModuleId(1));
index.set_aliases(vec![alias("/p/one", "@/", true, &["/p/one/src"])]);
let resolve = |importer: &str| {
index.against_aliases("@/x", Path::new(importer), |candidate| {
index.get(&append_extension(candidate, "ts"))
})
};
assert_eq!(resolve("/p/one/a.ts"), Some(ModuleId(1)));
assert_eq!(resolve("/p/two/b.ts"), None);
}
#[test]
fn the_index_knows_which_bases_could_name_a_module_without_trying_them() {
let mut index = ModuleIndex::new(vec![PathBuf::from("/p")]);
for (id, path) in [
"/p/src/a.ts",
"/p/src/orders.service.ts",
"/p/src/types.d.ts",
"/p/src/ui/index.tsx",
]
.iter()
.enumerate()
{
index.insert(PathBuf::from(path), ModuleId(id as u32));
}
for yes in [
"/p/src/a", "/p/src/a.js", "/p/src/orders.service", "/p/src/types", "/p/src/ui", ] {
assert!(index.could_name(Path::new(yes)), "{yes}");
}
for no in ["/p/src/react", "/p/node_modules/react", "/p/src/ui/missing"] {
assert!(!index.could_name(Path::new(no)), "{no}");
}
}
#[test]
fn normalize_collapses_dot_segments() {
assert_eq!(
normalize(Path::new("/p/src/./deep/../shared")),
PathBuf::from("/p/src/shared")
);
}
#[test]
fn climbing_above_the_root_does_not_panic() {
assert_eq!(
normalize(Path::new("/p/../../x")),
PathBuf::from("/../x"),
"the excess `..` survives as a path nothing in the index can match"
);
}
#[test]
fn append_extension_keeps_dots_already_in_the_name() {
assert_eq!(
append_extension(Path::new("/p/a.spec"), "ts"),
PathBuf::from("/p/a.spec.ts")
);
}
}