use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
use ignore::WalkBuilder;
use super::{DepTarget, Ecosystem, Edge, Manifest, Project, ProjectGraph, Scope, reader};
use crate::walk::TEST_DIRS;
const SKIPPED_DIRS: &[&str] = &["testdata", ".git", "node_modules", "vendor"];
const OUTPUT_DIRS: &[&str] = &["deps", "_build", "target"];
const SOURCE_DIRS: &[&str] = &["src", "lib", "app"];
const FIXTURE_DIRS: &[&str] = &["fixtures", "__fixtures__"];
pub fn is_skipped(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
let under = |dirs: &[&str]| {
path.components()
.filter_map(|c| c.as_os_str().to_str())
.any(|c| dirs.contains(&c))
};
SKIPPED_DIRS.contains(&name)
|| (OUTPUT_DIRS.contains(&name) && !under(SOURCE_DIRS))
|| (FIXTURE_DIRS.contains(&name) && under(TEST_DIRS))
}
struct Found {
dir: PathBuf,
path: PathBuf,
ecosystem: Ecosystem,
manifest: Manifest,
}
fn normalize(path: &Path) -> Option<PathBuf> {
let mut parts: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
parts.pop()?;
}
other => parts.push(other),
}
}
Some(parts.iter().collect())
}
pub fn is_manifest(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.is_some_and(|name| reader(name).is_some())
}
fn read_manifest(path: &Path, source: &str) -> Option<Found> {
let (ecosystem, read) = reader(path.file_name()?.to_str()?)?;
Some(Found {
dir: path.parent().unwrap_or(Path::new("")).to_path_buf(),
path: path.to_path_buf(),
ecosystem,
manifest: read(source),
})
}
pub fn files_on_disk(root: &Path, wanted: impl Fn(&Path) -> bool) -> Vec<(PathBuf, String)> {
let relative = |path: &Path| path.strip_prefix(root).unwrap_or(path).to_path_buf();
let walk = WalkBuilder::new(root)
.hidden(false)
.filter_entry({
let root = root.to_path_buf();
move |entry| !is_skipped(entry.path().strip_prefix(&root).unwrap_or(entry.path()))
})
.build();
walk.flatten()
.map(|entry| relative(entry.path()))
.filter(|path| wanted(path))
.filter_map(|path| {
let text = std::fs::read_to_string(root.join(&path)).ok()?;
Some((path, text))
})
.collect()
}
fn dir_name(dir: &Path) -> String {
dir.file_name()
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().into_owned())
}
enum Resolved {
Project(usize),
External,
Unread,
}
#[derive(Default)]
struct Resolver {
projects: Vec<Project>,
by_root: HashMap<PathBuf, usize>,
by_name: HashMap<(Ecosystem, String), usize>,
workspaces: HashMap<PathBuf, HashMap<String, PathBuf>>,
}
impl Resolver {
fn register_workspace(&mut self, found: &Found) {
if !found.manifest.is_workspace_root {
return;
}
let offered = found
.manifest
.workspace_paths
.iter()
.filter_map(|(name, p)| Some((name.clone(), normalize(&found.dir.join(p))?)));
self.workspaces
.entry(found.dir.clone())
.or_default()
.extend(offered);
}
fn project_name(&self, found: &Found) -> Option<String> {
match found.ecosystem {
Ecosystem::Npm if self.workspaces.contains_key(&found.dir) => None,
Ecosystem::Npm if found.path.ends_with("package.json") => Some(
found
.manifest
.name
.clone()
.unwrap_or_else(|| dir_name(&found.dir)),
),
_ => found.manifest.name.clone(),
}
}
fn register_project(&mut self, found: &Found) {
let Some(name) = self.project_name(found) else {
return;
};
let next = self.projects.len();
let index = *self.by_root.entry(found.dir.clone()).or_insert(next);
if index == next {
self.projects.push(Project {
root: found.dir.clone(),
name: name.clone(),
});
}
self.by_name.insert((found.ecosystem, name), index);
}
fn at_path(&self, dir: &Path, relative: &Path) -> Resolved {
normalize(&dir.join(relative))
.and_then(|p| self.by_root.get(&p))
.map_or(Resolved::Unread, |&i| Resolved::Project(i))
}
fn resolve(&self, found: &Found, target: &DepTarget) -> Resolved {
match target {
DepTarget::Path(relative) => self.at_path(&found.dir, relative),
DepTarget::Name(name) => self
.by_name
.get(&(found.ecosystem, name.clone()))
.map_or(Resolved::External, |&i| Resolved::Project(i)),
DepTarget::Workspace(name) => found
.dir
.ancestors()
.find_map(|dir| self.workspaces.get(dir))
.and_then(|table| table.get(name))
.map_or(Resolved::External, |dir| self.at_path(Path::new(""), dir)),
}
}
fn dependencies(&self, found: &Found) -> (Vec<(usize, Scope)>, usize) {
let mut local = Vec::new();
let mut unread = found.manifest.unread;
for dep in &found.manifest.deps {
match self.resolve(found, &dep.target) {
Resolved::Project(to) => local.push((to, dep.scope)),
Resolved::Unread => unread += 1,
Resolved::External => {}
}
}
(local, unread)
}
}
impl ProjectGraph {
pub fn discover(root: &Path) -> Self {
Self::from_manifests(files_on_disk(root, is_manifest))
}
pub fn from_manifests(manifests: Vec<(PathBuf, String)>) -> Self {
let mut found: Vec<Found> = manifests
.iter()
.filter_map(|(path, text)| read_manifest(path, text))
.collect();
found.sort_by(|a, b| a.path.cmp(&b.path));
let mut resolver = Resolver::default();
for f in &found {
resolver.register_workspace(f);
}
for f in &found {
resolver.register_project(f);
}
let mut scopes: HashMap<(usize, usize), Scope> = HashMap::new();
let mut unread = Vec::new();
for f in &found {
let (local, missing) = resolver.dependencies(f);
if missing > 0 {
unread.push((f.path.clone(), missing));
}
let Some(&from) = resolver.by_root.get(&f.dir) else {
continue;
};
for (to, scope) in local.into_iter().filter(|&(to, _)| to != from) {
let kept = scopes.entry((from, to)).or_insert(scope);
*kept = scope.min(*kept);
}
}
let mut edges: Vec<Edge> = scopes
.into_iter()
.map(|((from, to), scope)| Edge { from, to, scope })
.collect();
edges.sort_by_key(|e| (e.to, e.from));
let mut workspaces: Vec<PathBuf> = resolver.workspaces.into_keys().collect();
workspaces.sort();
ProjectGraph {
projects: resolver.projects,
edges,
workspaces,
unread,
}
}
}