use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use futures::stream::{self, StreamExt};
use crate::analysis::findings::Finding;
use crate::analysis::result::{FailureReason, union_failures};
use crate::cli::check::input::Work;
use crate::languages;
use crate::languages::runner::{self};
use crate::languages::spec::ToolSpec;
const TOOL_PROCESS_CONCURRENCY: usize = 4;
pub async fn run(
work: &Work,
root: &Path,
) -> (
Vec<Finding>,
BTreeMap<PathBuf, FailureReason>,
BTreeSet<PathBuf>,
) {
let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
let (tasks, unconfigured) = plan_tasks(work, root);
let base = runner::absolute(root);
let differences = Differences {
differing: differing_paths(&base, &work.uncommitted),
index_only: differing_paths(&base, &work.index_only),
};
let (tasks, refused) = partition_uncommitted(tasks, &base, &differences);
for (task, paths) in refused {
let reason = FailureReason::UncommittedChanges {
tool: task.spec.name.to_owned(),
paths,
};
fail_all(
task.files.into_iter().map(|file| file.original),
&reason,
&mut failures,
);
}
fail_uncommitted_markers(unconfigured, &base, &differences, &mut failures);
let (serial, parallel): (Vec<_>, Vec<_>) = tasks
.into_iter()
.partition(|task| task.spec.serial_in_repository);
let parallel = stream::iter(parallel)
.map(|task| run_one(task, root))
.buffer_unordered(TOOL_PROCESS_CONCURRENCY)
.collect::<Vec<_>>();
let serial = async {
let mut outcomes = Vec::with_capacity(serial.len());
for task in serial {
outcomes.push(run_one(task, root).await);
}
outcomes
};
let (mut outcomes, serial_outcomes) = tokio::join!(parallel, serial);
outcomes.extend(serial_outcomes);
let mut findings = Vec::new();
let mut compiled = BTreeSet::new();
for (outcome, files) in outcomes {
merge_outcome(outcome, files, &mut failures, &mut findings, &mut compiled);
}
(findings, failures, compiled)
}
struct PlannedTask {
spec: &'static ToolSpec,
workspace_root: PathBuf,
files: Vec<PlannedFile>,
}
struct Unconfigured {
spec: &'static ToolSpec,
file: PathBuf,
absolute: PathBuf,
}
struct PlannedFile {
original: PathBuf,
absolute: PathBuf,
argument: String,
}
async fn run_one(task: PlannedTask, root: &Path) -> (runner::ToolOutcome, Vec<PathBuf>) {
let PlannedTask {
spec,
workspace_root,
files,
..
} = task;
let mut arguments = Vec::with_capacity(files.len());
let mut originals = Vec::with_capacity(files.len());
let mut original_by_absolute = BTreeMap::new();
for file in files {
arguments.push(file.argument);
originals.push(file.original.clone());
original_by_absolute.insert(file.absolute, file.original);
}
let mut outcome = runner::run_tool_at(spec, root, &workspace_root, &arguments).await;
let mut original_by_canonical: Option<BTreeMap<PathBuf, PathBuf>> = None;
for finding in &mut outcome.findings {
let joined = runner::joined_reported(&workspace_root, &finding.file_path);
let original = match original_by_absolute.get(&joined) {
Some(original) => Some(original),
None => joined.canonicalize().ok().and_then(|resolved| {
original_by_canonical
.get_or_insert_with(|| {
original_by_absolute
.iter()
.filter_map(|(absolute, original)| {
Some((absolute.canonicalize().ok()?, original.clone()))
})
.collect()
})
.get(&resolved)
}),
};
if let Some(original) = original {
finding.file_path = original.to_string_lossy().into_owned();
}
}
(outcome, originals)
}
struct Differing {
absolute: PathBuf,
named: PathBuf,
}
struct Differences {
differing: Vec<Differing>,
index_only: Vec<Differing>,
}
impl Differences {
fn markers(&self, spec: &ToolSpec, directories: &BTreeSet<PathBuf>) -> Vec<PathBuf> {
let links = marker_links(spec, directories);
let is_marker = |path: &Differing| {
names_marker(spec, directories, &path.absolute) || links.contains(&path.absolute)
};
let mut paths = named_where(&self.differing, is_marker);
paths.extend(named_where(&self.index_only, is_marker));
paths.sort();
paths
}
}
fn differing_paths(base: &Path, uncommitted: &BTreeSet<PathBuf>) -> Vec<Differing> {
uncommitted
.iter()
.map(|named| Differing {
absolute: runner::lexically_normal(&base.join(named)),
named: named.clone(),
})
.collect()
}
fn named_where(differing: &[Differing], reads: impl Fn(&Differing) -> bool) -> Vec<PathBuf> {
differing
.iter()
.filter(|path| reads(path))
.map(|path| path.named.clone())
.collect()
}
fn partition_uncommitted(
tasks: Vec<PlannedTask>,
base: &Path,
differences: &Differences,
) -> (Vec<PlannedTask>, Vec<(PlannedTask, Vec<PathBuf>)>) {
if differences.differing.is_empty() && differences.index_only.is_empty() {
return (tasks, Vec::new());
}
let mut runnable = Vec::new();
let mut refused = Vec::new();
for task in tasks {
let reads = task_uncommitted_reads(&task, base, differences);
if reads.is_empty() {
runnable.push(task);
} else {
refused.push((task, reads));
}
}
(runnable, refused)
}
fn task_uncommitted_reads(
task: &PlannedTask,
base: &Path,
differences: &Differences,
) -> Vec<PathBuf> {
let directories =
marker_directories(task.files.iter().map(|file| file.absolute.as_path()), base);
let files: Vec<PathBuf> = task
.files
.iter()
.map(|file| runner::lexically_normal(&file.absolute))
.collect();
let mut reads = differences.markers(task.spec, &directories);
reads.extend(named_where(&differences.index_only, |path| {
files.contains(&path.absolute)
}));
if task.spec.reads_other_sources {
reads.extend(named_where(&differences.differing, |_| true));
} else {
let targets = canonical_reads(task, &directories);
reads.extend(named_where(&differences.differing, |path| {
files.contains(&path.absolute)
|| path
.absolute
.canonicalize()
.is_ok_and(|target| targets.contains(&target))
}));
}
reads.sort();
reads.dedup();
reads
}
fn marker_links(spec: &ToolSpec, directories: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
let mut targets = BTreeSet::new();
for directory in directories {
for name in spec
.config_files
.iter()
.filter(|name| !name.starts_with("*."))
{
let mut link = directory.join(name);
while let Ok(target) = std::fs::read_link(&link) {
let next = runner::lexically_normal(&match link.parent() {
Some(parent) => parent.join(&target),
None => target,
});
if !targets.insert(next.clone()) {
break;
}
link = next;
}
}
}
targets
}
fn canonical_reads(task: &PlannedTask, directories: &BTreeSet<PathBuf>) -> BTreeSet<PathBuf> {
let markers = directories.iter().filter_map(|directory| {
runner::configured_marker(task.spec, directory).map(|name| directory.join(name))
});
task.files
.iter()
.map(|file| file.absolute.clone())
.chain(markers)
.filter_map(|path| path.canonicalize().ok())
.collect()
}
fn marker_directories<'a>(files: impl Iterator<Item = &'a Path>, base: &Path) -> BTreeSet<PathBuf> {
files
.filter_map(Path::parent)
.flat_map(|directory| runner::ancestors_within(directory, base))
.collect()
}
fn names_marker(spec: &ToolSpec, directories: &BTreeSet<PathBuf>, path: &Path) -> bool {
directories.iter().any(|directory| {
path.starts_with(directory)
&& spec
.config_files
.iter()
.any(|name| runner::marker_names_path(directory, name, path))
})
}
fn fail_uncommitted_markers(
unconfigured: Vec<Unconfigured>,
base: &Path,
differences: &Differences,
failures: &mut BTreeMap<PathBuf, FailureReason>,
) {
if differences.differing.is_empty() && differences.index_only.is_empty() {
return;
}
for pair in unconfigured {
let directories = marker_directories(std::iter::once(pair.absolute.as_path()), base);
let paths = differences.markers(pair.spec, &directories);
if !paths.is_empty() {
let reason = FailureReason::UncommittedChanges {
tool: pair.spec.name.to_owned(),
paths,
};
fail_all([pair.file], &reason, failures);
}
}
}
fn plan_tasks(work: &Work, root: &Path) -> (Vec<PlannedTask>, Vec<Unconfigured>) {
let paths: Vec<&Path> = work
.by_file
.iter()
.filter_map(|hunks| hunks.first())
.map(|hunk| hunk.file_path.as_path())
.chain(work.lint_only.iter().map(PathBuf::as_path))
.collect();
let repository_root = runner::absolute(root);
let mut tasks = Vec::new();
let mut unconfigured = Vec::new();
for (language, files) in languages::group_by_language(&paths) {
for spec in language.tools {
let mut workspaces: BTreeMap<PathBuf, Vec<PlannedFile>> = BTreeMap::new();
for file in &files {
let absolute = if file.is_absolute() {
(*file).to_path_buf()
} else {
repository_root.join(file)
};
let Some(workspace_root) = runner::configuration_root(spec, &repository_root, file)
else {
unconfigured.push(Unconfigured {
spec,
file: (*file).to_path_buf(),
absolute,
});
continue;
};
let Ok(relative) = absolute.strip_prefix(&workspace_root) else {
continue;
};
let argument = relative.to_string_lossy().into_owned();
workspaces
.entry(workspace_root)
.or_default()
.push(PlannedFile {
original: (*file).to_path_buf(),
absolute,
argument,
});
}
tasks.extend(
workspaces
.into_iter()
.map(|(workspace_root, files)| PlannedTask {
spec,
workspace_root,
files,
}),
);
}
}
(tasks, unconfigured)
}
fn merge_outcome(
outcome: runner::ToolOutcome,
files: Vec<PathBuf>,
failures: &mut BTreeMap<PathBuf, FailureReason>,
findings: &mut Vec<Finding>,
compiled: &mut BTreeSet<PathBuf>,
) {
if outcome.compilation_succeeded {
compiled.extend(files.iter().cloned());
}
match outcome.status {
runner::ToolStatus::Ok => {
findings.extend(outcome.findings);
}
runner::ToolStatus::Skipped => {
}
runner::ToolStatus::Unavailable => {
let reason = FailureReason::ToolUnavailable {
tool: outcome.tool.to_owned(),
detail: outcome.detail,
};
fail_all(files, &reason, failures);
}
}
}
fn fail_all(
files: impl IntoIterator<Item = PathBuf>,
reason: &FailureReason,
failures: &mut BTreeMap<PathBuf, FailureReason>,
) {
let batch: BTreeMap<PathBuf, FailureReason> = files
.into_iter()
.map(|file| (file, reason.clone()))
.collect();
union_failures(failures, batch);
}