use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use rayon::prelude::*;
use serde_json::{Value, json};
use skif::{
ConfigOverrides, Diagnostic, FileSystem, Linter, Location, ModuleCache, OsFs, Report, RuleMeta, RunOptions, Target,
Workspace, slash,
};
use crate::error::{CliError, Kind};
pub struct RootError {
pub prefix: PathBuf,
pub error: CliError,
}
impl RootError {
pub fn describe(&self) -> String {
format!("{}: {}", display(&self.prefix), self.error.message)
}
pub fn to_json(&self) -> Value {
json!({
"path": display(&self.prefix),
"kind": self.error.kind.as_str(),
"message": self.error.message,
})
}
}
pub struct Resolved {
pub base: PathBuf,
pub targets: Vec<Target>,
pub multi: bool,
pub failures: Vec<RootError>,
pub truncated: Vec<PathBuf>,
}
fn directory_of(start: &Path, fs: &dyn FileSystem) -> PathBuf {
if fs.is_dir(start) {
start.to_path_buf()
} else {
start.parent().unwrap_or(start).to_path_buf()
}
}
fn prefix_of(base: &Path, root: &Path) -> PathBuf {
root.strip_prefix(base).unwrap_or(root).to_path_buf()
}
pub fn resolve(
start: &Path,
recursive: bool,
fs: &dyn FileSystem,
overrides: &ConfigOverrides,
) -> Result<Resolved, CliError> {
let found = if recursive {
None
} else {
match skif::find_target(start, fs, overrides) {
Ok(target) => Some(target),
Err(skif::Error::NoWorkspace(_)) => None,
Err(error) => return Err(error.into()),
}
};
if let Some(target) = found {
return Ok(Resolved {
base: target.root.clone(),
targets: vec![target],
multi: false,
failures: Vec::new(),
truncated: Vec::new(),
});
}
let base = directory_of(start, fs);
let discovery = skif::discover_targets(start, fs, overrides)?;
let mut failures = discovery.failures.into_iter();
if discovery.targets.is_empty() {
return Err(match failures.next() {
Some(failure) => {
let mut error = CliError::from(failure.error);
error.message = format!("{}: {}", display(&prefix_of(&base, &failure.root)), error.message);
error
}
None => CliError::new(
Kind::Workspace,
format!("no workspace or package found at or below {}", start.display()),
),
});
}
Ok(Resolved {
failures: failures
.map(|failure| RootError {
prefix: prefix_of(&base, &failure.root),
error: failure.error.into(),
})
.collect(),
truncated: discovery.truncated.iter().map(|dir| prefix_of(&base, dir)).collect(),
base,
targets: discovery.targets,
multi: true,
})
}
pub struct Linters {
linters: Vec<Linter>,
of_target: Vec<usize>,
}
impl Linters {
pub fn build(targets: &[Target]) -> Result<Self, CliError> {
Self::build_with(targets, None, None)
}
pub fn build_with(
targets: &[Target],
fs: Option<&Arc<dyn FileSystem>>,
modules: Option<&Arc<dyn ModuleCache>>,
) -> Result<Self, CliError> {
let mut linters: Vec<Linter> = Vec::new();
let mut of_target = Vec::with_capacity(targets.len());
for target in targets {
let index = if let Some(index) = linters.iter().position(|linter| linter.config() == &target.config) {
index
} else {
let fs = fs.map_or_else(|| Arc::new(OsFs) as Arc<dyn FileSystem>, Arc::clone);
linters.push(match modules {
Some(modules) => Linter::with_module_cache(&target.config, fs, Arc::clone(modules))?,
None => Linter::with_fs(&target.config, fs)?,
});
linters.len() - 1
};
of_target.push(index);
}
Ok(Self { linters, of_target })
}
pub fn get(&self, index: usize) -> &Linter {
&self.linters[index]
}
pub fn rules(&self) -> Vec<&'static RuleMeta> {
let mut rules: BTreeMap<&'static str, &'static RuleMeta> = BTreeMap::new();
for meta in self.linters.iter().flat_map(Linter::rules) {
rules.entry(meta.id).or_insert(meta);
}
rules.into_values().collect()
}
}
pub struct RootRun {
pub prefix: PathBuf,
pub config: skif::Config,
pub config_path: Option<PathBuf>,
pub linter: usize,
pub workspace: Workspace,
pub report: Report,
pub extra_entries: Vec<PathBuf>,
}
pub fn lint_loaded(
linter: &Linter,
workspace: &Workspace,
config_path: Option<&Path>,
extra_entries: &[PathBuf],
) -> Result<Report, CliError> {
let mut options = RunOptions::default().with_extra_entries(extra_entries);
if let Some(path) = config_path {
options = options.with_config_path(path);
}
Ok(linter.run_with(workspace, &options)?)
}
fn nesting_depths(targets: &[Target]) -> Vec<usize> {
targets
.iter()
.map(|target| {
targets
.iter()
.filter(|other| other.root != target.root && target.root.starts_with(&other.root))
.count()
})
.collect()
}
fn owning_root(targets: &[Target], file: &Path) -> Option<usize> {
targets
.iter()
.enumerate()
.filter(|(_, target)| file.starts_with(&target.root))
.max_by_key(|(_, target)| target.root.components().count())
.map(|(index, _)| index)
}
fn route_other_project_files(
targets: &[Target],
depths: &[usize],
from: usize,
report: &Report,
extra: &mut [BTreeSet<PathBuf>],
) {
for file in &report.other_project_files {
let abs = targets[from].root.join(file);
let Some(owner) = owning_root(targets, &abs).filter(|owner| depths[*owner] > depths[from]) else {
continue;
};
if let Ok(rel) = abs.strip_prefix(&targets[owner].root) {
extra[owner].insert(rel.to_path_buf());
}
}
}
pub fn lint_targets(base: &Path, targets: Vec<Target>, linters: &Linters) -> Result<Vec<RootRun>, CliError> {
let depths = nesting_depths(&targets);
let mut extra: Vec<BTreeSet<PathBuf>> = vec![BTreeSet::new(); targets.len()];
let mut reports: Vec<Option<Result<Report, CliError>>> = (0..targets.len()).map(|_| None).collect();
for depth in 0..=depths.iter().copied().max().unwrap_or(0) {
let wave: Vec<usize> = (0..targets.len()).filter(|index| depths[*index] == depth).collect();
let linted: Vec<(usize, Result<Report, CliError>)> = wave
.par_iter()
.map(|&index| {
let target = &targets[index];
let entries: Vec<PathBuf> = extra[index].iter().cloned().collect();
let linter = linters.get(linters.of_target[index]);
(
index,
lint_loaded(linter, &target.workspace, target.config_path().as_deref(), &entries),
)
})
.collect();
for (index, report) in linted {
if let Ok(report) = &report {
route_other_project_files(&targets, &depths, index, report, &mut extra);
}
reports[index] = Some(report);
}
}
targets
.into_iter()
.zip(reports)
.zip(extra)
.zip(&linters.of_target)
.map(|(((target, report), extra_entries), &linter)| {
let report = report.unwrap_or_else(|| Err(CliError::new(Kind::Internal, "a root was not linted")))?;
Ok(RootRun {
prefix: prefix_of(base, &target.root),
config_path: target.config_path(),
config: target.config,
linter,
workspace: target.workspace,
report,
extra_entries: extra_entries.into_iter().collect(),
})
})
.collect()
}
fn join(prefix: &Path, path: &Path) -> PathBuf {
let mut joined = PathBuf::new();
for component in prefix.components().chain(path.components()) {
match component {
Component::ParentDir if joined.pop() => {}
other => joined.push(other),
}
}
joined
}
fn relocate(location: &mut Location, prefix: &Path) {
location.path = join(prefix, &location.path);
}
pub fn prefixed<'a>(report: &'a Report, prefix: &Path) -> Cow<'a, Report> {
if prefix.as_os_str().is_empty() {
return Cow::Borrowed(report);
}
let diagnostics: Vec<Diagnostic> = report
.diagnostics
.iter()
.cloned()
.map(|mut diagnostic| {
relocate(&mut diagnostic.location, prefix);
for related in &mut diagnostic.related {
relocate(&mut related.location, prefix);
}
diagnostic
})
.collect();
let incomplete = report
.incomplete_packages
.iter()
.cloned()
.map(|mut package| {
package.dir = join(prefix, &package.dir);
for reason in &mut package.reasons {
reason.file = reason.file.take().map(|file| join(prefix, &file));
}
package
})
.collect();
Cow::Owned(Report::new(diagnostics, report.summary.packages).with_incomplete_packages(incomplete))
}
pub fn aggregate<'a>(reports: &'a [Cow<'_, Report>]) -> Cow<'a, Report> {
if let [report] = reports {
return Cow::Borrowed(report);
}
Cow::Owned(
Report::new(
reports
.iter()
.flat_map(|report| report.diagnostics.iter().cloned())
.collect(),
reports.iter().map(|report| report.summary.packages).sum(),
)
.with_incomplete_packages(
reports
.iter()
.flat_map(|report| report.incomplete_packages.iter().cloned())
.collect(),
),
)
}
pub fn display(prefix: &Path) -> String {
if prefix.as_os_str().is_empty() {
".".to_owned()
} else {
slash(prefix)
}
}
pub fn root_json(run: &RootRun, report: &Report) -> Value {
let packages: Vec<String> = run
.workspace
.all_packages()
.filter(|package| !run.workspace.is_ignored(package.id))
.map(|package| display(&join(&run.prefix, &package.dir)))
.collect();
json!({
"path": display(&run.prefix),
"packages": packages,
"summary": serde_json::to_value(report.summary).unwrap_or(Value::Null),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn join_resolves_parent_segments() {
assert_eq!(
join(Path::new("a/b"), Path::new("package.json")),
Path::new("a/b/package.json")
);
assert_eq!(join(Path::new("a"), Path::new("../skif.toml")), Path::new("skif.toml"));
assert_eq!(join(Path::new(""), Path::new("x")), Path::new("x"));
}
#[test]
fn display_names_the_base_as_a_dot() {
assert_eq!(display(Path::new("")), ".");
assert_eq!(display(Path::new("a/b")), "a/b");
}
#[test]
fn a_root_outside_the_base_keeps_its_full_path() {
assert_eq!(prefix_of(Path::new("/a"), Path::new("/a/b/c")), Path::new("b/c"));
assert_eq!(prefix_of(Path::new("/a"), Path::new("/a")), Path::new(""));
assert_eq!(prefix_of(Path::new("/a"), Path::new("/z/b")), Path::new("/z/b"));
}
#[test]
fn prefixing_with_the_base_borrows_and_otherwise_relocates() {
let report = Report::new(Vec::new(), 3);
assert!(matches!(prefixed(&report, Path::new("")), Cow::Borrowed(_)));
assert!(matches!(prefixed(&report, Path::new("a")), Cow::Owned(_)));
let both = [prefixed(&report, Path::new("a")), prefixed(&report, Path::new(""))];
assert_eq!(aggregate(&both).summary.packages, 6);
}
#[test]
fn a_root_error_names_the_root() {
let error = RootError {
prefix: PathBuf::from("pkgs/a"),
error: CliError::new(Kind::Workspace, "boom"),
};
assert_eq!(error.describe(), "pkgs/a: boom");
assert_eq!(error.to_json()["path"], "pkgs/a");
assert_eq!(error.to_json()["kind"], "workspace");
}
#[test]
fn prefixing_and_aggregating_relocate_incomplete_packages() {
use skif::{IncompleteCause, IncompleteEntries, IncompletePackage};
let package = |name: &str, dir: &str, file: &str| {
IncompletePackage::new(
name.to_owned(),
dir.into(),
vec![IncompleteEntries {
cause: IncompleteCause::UnknownSpread,
plugin: Some("vite".to_owned()),
file: Some(file.into()),
}],
)
};
let nested = Report::new(Vec::new(), 1).with_incomplete_packages(vec![package(".", "", "vite.config.ts")]);
let base = Report::new(Vec::new(), 1).with_incomplete_packages(vec![package("z", "z", "z/vite.config.ts")]);
let reports = [prefixed(&base, Path::new("")), prefixed(&nested, Path::new("apps/web"))];
let merged = aggregate(&reports);
let shown: Vec<(PathBuf, Option<PathBuf>)> = merged
.incomplete_packages
.iter()
.map(|package| (package.dir.clone(), package.reasons[0].file.clone()))
.collect();
assert_eq!(
shown,
[
(
PathBuf::from("apps/web"),
Some(PathBuf::from("apps/web/vite.config.ts"))
),
(PathBuf::from("z"), Some(PathBuf::from("z/vite.config.ts"))),
]
);
}
}