use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use crate::analysis::findings::Finding;
use crate::languages::spec::{DEFAULT_TOOL_TIMEOUT_SECS, DiagnosticsStream, ToolSpec};
mod narrow;
pub mod parsers;
mod uri;
pub use parsers::parse_output;
pub(crate) use narrow::joined_reported;
use narrow::retain_requested;
#[cfg(test)]
mod tests;
pub const TOOL_TIMEOUT: Duration = Duration::from_secs(DEFAULT_TOOL_TIMEOUT_SECS);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToolStatus {
Ok,
Skipped,
Unavailable,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolOutcome {
pub tool: &'static str,
pub status: ToolStatus,
pub findings: Vec<Finding>,
pub detail: String,
pub compilation_succeeded: bool,
}
impl ToolOutcome {
fn skipped(spec: &ToolSpec) -> Self {
let listed = spec.config_files[..spec.config_files.len().min(3)].join(", ");
Self::empty(
spec,
ToolStatus::Skipped,
format!("not configured (add one of: {listed})"),
)
}
fn unavailable(spec: &ToolSpec, detail: String) -> Self {
Self::empty(spec, ToolStatus::Unavailable, detail)
}
fn ready(spec: &ToolSpec) -> Self {
Self::empty(spec, ToolStatus::Ok, "ready".to_owned())
}
fn empty(spec: &ToolSpec, status: ToolStatus, detail: String) -> Self {
Self {
tool: spec.name,
status,
findings: Vec::new(),
detail,
compilation_succeeded: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ToolOutputError(pub String);
impl std::fmt::Display for ToolOutputError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for ToolOutputError {}
pub(crate) fn is_executable(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
match path.metadata() {
Ok(meta) => meta.is_file() && meta.permissions().mode() & 0o111 != 0,
Err(_) => false,
}
}
#[cfg(not(unix))]
{
path.is_file()
}
}
pub fn resolve_tool(spec: &ToolSpec, root: &Path) -> Option<PathBuf> {
resolve_tool_in(spec, root, std::env::var_os("PATH").as_deref())
}
pub(crate) fn resolve_tool_in(
spec: &ToolSpec,
root: &Path,
path: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
resolve_tool_at(spec, root, root, path)
}
fn resolve_tool_at(
spec: &ToolSpec,
repository_root: &Path,
workspace_root: &Path,
path: Option<&std::ffi::OsStr>,
) -> Option<PathBuf> {
for directory in ancestors_within(workspace_root, repository_root) {
for relative in spec.local_paths {
let candidate = directory.join(relative);
if is_executable(&candidate) {
return Some(candidate);
}
}
}
let name = spec.command.first()?;
which_first_in(name, path?).map(PathBuf::from)
}
pub(crate) fn absolute(path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.unwrap_or_else(|_| path.to_path_buf())
}
}
fn ancestors_within(start: &Path, root: &Path) -> Vec<PathBuf> {
let root = absolute(root);
let start = absolute(start);
if !start.starts_with(&root) {
return Vec::new();
}
start
.ancestors()
.take_while(|ancestor| ancestor.starts_with(&root))
.map(Path::to_path_buf)
.collect()
}
fn which_first_in(command: &str, path: &std::ffi::OsStr) -> Option<String> {
for dir in std::env::split_paths(path) {
let candidate = dir.join(command);
if is_executable(&candidate) {
return Some(candidate.to_string_lossy().into_owned());
}
}
None
}
pub fn is_configured(spec: &ToolSpec, root: &Path) -> bool {
configured_marker(spec, root).is_some()
}
fn configured_marker(spec: &ToolSpec, root: &Path) -> Option<String> {
spec.config_files
.iter()
.find_map(|name| marker_match(root, name))
}
fn marker_match(root: &Path, name: &str) -> Option<String> {
let Some(extension) = name.strip_prefix("*.") else {
return root.join(name).exists().then(|| name.to_owned());
};
let Ok(entries) = std::fs::read_dir(root) else {
return None;
};
entries.flatten().find_map(|entry| {
let found = entry.file_name();
let matches = Path::new(&found)
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case(extension))
&& entry.file_type().is_ok_and(|kind| kind.is_file());
matches.then(|| found.to_string_lossy().into_owned())
})
}
pub(crate) fn configuration_root(
spec: &ToolSpec,
repository_root: &Path,
file: &Path,
) -> Option<PathBuf> {
let repository_root = absolute(repository_root);
let file = if file.is_absolute() {
file.to_path_buf()
} else {
repository_root.join(file)
};
ancestors_within(file.parent()?, &repository_root)
.into_iter()
.find(|directory| is_configured(spec, directory))
}
pub fn tool_status(spec: &ToolSpec, root: &Path) -> ToolOutcome {
tool_status_at(spec, root, root)
}
pub async fn run_tool(spec: &ToolSpec, root: &Path, files: &[String]) -> ToolOutcome {
run_tool_at(spec, root, root, files).await
}
pub(crate) async fn run_tool_at(
spec: &ToolSpec,
repository_root: &Path,
workspace_root: &Path,
files: &[String],
) -> ToolOutcome {
let executable = match eligible_executable(spec, repository_root, workspace_root) {
Ok(executable) => executable,
Err(outcome) => return outcome,
};
let executable = if executable.is_relative() {
std::env::current_dir()
.map(|cwd| cwd.join(&executable))
.unwrap_or(executable)
} else {
executable
};
let mut argv: Vec<String> = Vec::with_capacity(spec.command.len() + files.len() + 2);
argv.push(executable.to_string_lossy().into_owned());
argv.extend(spec.command[1..].iter().map(|s| (*s).to_owned()));
if let Some(flag) = spec.config_flag
&& let Some(config) = configured_marker(spec, workspace_root)
{
argv.push(flag.to_owned());
argv.push(config);
}
if spec.accepts_files {
argv.extend(files.iter().map(|f| {
if f.starts_with('-') {
format!("./{f}")
} else {
f.clone()
}
}));
}
let mut command = Command::new(&argv[0]);
command.args(&argv[1..]);
command.current_dir(workspace_root);
command.stdin(Stdio::null());
command.stdout(Stdio::piped());
command.stderr(Stdio::piped());
command.kill_on_drop(true);
let child = match command.spawn() {
Ok(child) => child,
Err(err) => {
return ToolOutcome::unavailable(
spec,
format!("{} could not be executed: {err}", spec.name),
);
}
};
let timeout = Duration::from_secs(spec.timeout_secs);
let output = match tokio::time::timeout(timeout, child.wait_with_output()).await {
Ok(Ok(output)) => output,
Ok(Err(err)) => {
return ToolOutcome::unavailable(
spec,
format!("{} could not be executed: {err}", spec.name),
);
}
Err(_) => {
let context = spec.timeout_context.unwrap_or_default();
return ToolOutcome::unavailable(
spec,
format!(
"{} timed out after {}s{context}",
spec.name, spec.timeout_secs
),
);
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let (diagnostics, other) = match spec.diagnostics_stream {
DiagnosticsStream::Stderr => (stderr.as_ref(), stdout.as_ref()),
DiagnosticsStream::Stdout => (stdout.as_ref(), stderr.as_ref()),
};
let parse_result = parse_output(
spec,
diagnostics,
files.first().map(String::as_str).unwrap_or(""),
);
let compilation_succeeded = spec.establishes_compilation && output.status.success();
match parse_result {
Ok(findings)
if findings.is_empty() && !output.status.success() && diagnostics.trim().is_empty() =>
{
ToolOutcome::unavailable(
spec,
format!(
"{} exited {} without producing diagnostics: {}",
spec.name,
exit_word(&output.status),
stream_detail(other)
),
)
}
Ok(findings)
if findings.is_empty()
&& !output.status.success()
&& spec.output_format.skips_unmatched_input() =>
{
ToolOutcome::unavailable(
spec,
format!(
"{} exited {} without a recognisable diagnostic: {}",
spec.name,
exit_word(&output.status),
stream_detail(diagnostics)
),
)
}
Ok(findings) => ToolOutcome {
tool: spec.name,
status: ToolStatus::Ok,
findings: retain_requested(spec, findings, files, workspace_root),
detail: stream_detail(other),
compilation_succeeded,
},
Err(err) => ToolOutcome::unavailable(
spec,
format!("{err}. other stream: {}", stream_detail(other)),
),
}
}
pub(crate) fn tool_status_at(
spec: &ToolSpec,
repository_root: &Path,
workspace_root: &Path,
) -> ToolOutcome {
match eligible_executable(spec, repository_root, workspace_root) {
Ok(_) => ToolOutcome::ready(spec),
Err(outcome) => outcome,
}
}
fn eligible_executable(
spec: &ToolSpec,
repository_root: &Path,
workspace_root: &Path,
) -> Result<PathBuf, ToolOutcome> {
if !is_configured(spec, workspace_root) {
return Err(ToolOutcome::skipped(spec));
}
if let Some(executable) = resolve_tool_at(
spec,
repository_root,
workspace_root,
std::env::var_os("PATH").as_deref(),
) {
return Ok(executable);
}
let looked = if spec.local_paths.is_empty() {
"PATH".to_owned()
} else if absolute(repository_root) == absolute(workspace_root) {
format!("{}, then PATH", spec.local_paths.join(", "))
} else {
format!(
"{} from {} through {}, then PATH",
spec.local_paths.join(", "),
workspace_root.display(),
repository_root.display()
)
};
Err(ToolOutcome::unavailable(
spec,
format!("configured but not found (looked in {looked})"),
))
}
pub(crate) fn stream_detail(stream: &str) -> String {
let trimmed = stream.trim();
if trimmed.is_empty() {
return String::new();
}
crate::text::excerpt(trimmed, DETAIL_MAX_CHARS)
}
const DETAIL_MAX_CHARS: usize = 200;
fn exit_word(status: &std::process::ExitStatus) -> String {
status
.code()
.map_or("by signal".to_owned(), |code| code.to_string())
}