|
| 1 | +//! Shelling out to `agda-unused` to find unused code in a project. |
| 2 | +//! |
| 3 | +//! Like [`crate::agda`], ArghDA does not analyse for unused code itself — it |
| 4 | +//! asks the external `agda-unused` tool and re-emits its findings as ArghDA |
| 5 | +//! diagnostics (the `unused-import` rule, `docs/arghda-spec.adoc`). If |
| 6 | +//! `agda-unused` is not on `PATH` it degrades gracefully (`available: false`) |
| 7 | +//! so the rest of the engine still works without it (the rule is opt-in |
| 8 | +//! behind `scan --unused`). |
| 9 | +//! |
| 10 | +//! `agda-unused` is a *project-level* analyser — it resolves the whole import |
| 11 | +//! graph from a root module — so this is a single pass over a source tree, |
| 12 | +//! not a per-file [`crate::lint::LintRule`]. Its `--json` output is a wrapper |
| 13 | +//! object `{ "type": "none" | "unused" | "error", "message": "…" }`; the |
| 14 | +//! findings live in `message` in the human-readable form |
| 15 | +//! |
| 16 | +//! ```text |
| 17 | +//! /abs/path/File.agda:line,col-col |
| 18 | +//! <category> '<name>' |
| 19 | +//! ``` |
| 20 | +//! |
| 21 | +//! which we parse into per-file [`Diagnostic`]s under the `unused-import` |
| 22 | +//! rule (severity `warn`, per the spec's rule table). |
| 23 | +
|
| 24 | +use crate::diagnostic::{Diagnostic, Severity}; |
| 25 | +use anyhow::{Context, Result}; |
| 26 | +use serde::Deserialize; |
| 27 | +use std::collections::BTreeSet; |
| 28 | +use std::path::{Path, PathBuf}; |
| 29 | +use std::process::Command; |
| 30 | + |
| 31 | +/// The rule name re-emitted findings carry. |
| 32 | +pub const RULE_NAME: &str = "unused-import"; |
| 33 | + |
| 34 | +/// Outcome of an `agda-unused` pass over a source tree. |
| 35 | +#[derive(Clone, Debug, Default)] |
| 36 | +pub struct UnusedOutcome { |
| 37 | + /// Whether the `agda-unused` binary was found and executed. |
| 38 | + pub available: bool, |
| 39 | + /// Findings re-emitted as diagnostics (rule = `unused-import`, warn). |
| 40 | + pub diagnostics: Vec<Diagnostic>, |
| 41 | + /// The last `type` field seen ("none" / "unused" / "error"), for surfacing |
| 42 | + /// the case where `agda-unused` itself errored (e.g. a type error). |
| 43 | + pub kind: Option<String>, |
| 44 | +} |
| 45 | + |
| 46 | +/// The `--json` wrapper `agda-unused` emits. |
| 47 | +#[derive(Debug, Deserialize)] |
| 48 | +struct UnusedJson { |
| 49 | + #[serde(rename = "type")] |
| 50 | + kind: String, |
| 51 | + #[serde(default)] |
| 52 | + message: String, |
| 53 | +} |
| 54 | + |
| 55 | +/// Run `agda-unused <root> --global --json -i <include_root>` for each root and |
| 56 | +/// union the findings (deduplicated by file + line + message). A missing |
| 57 | +/// binary yields `available: false` with no diagnostics. |
| 58 | +pub fn find_unused(include_root: &Path, roots: &[PathBuf]) -> Result<UnusedOutcome> { |
| 59 | + let mut outcome = UnusedOutcome::default(); |
| 60 | + let mut seen: BTreeSet<(PathBuf, Option<usize>, String)> = BTreeSet::new(); |
| 61 | + |
| 62 | + for root in roots { |
| 63 | + let Some(parsed) = run_one(root, include_root)? else { |
| 64 | + // Binary not found: report unavailable and stop. |
| 65 | + return Ok(UnusedOutcome::default()); |
| 66 | + }; |
| 67 | + outcome.available = true; |
| 68 | + outcome.kind = Some(parsed.kind.clone()); |
| 69 | + if parsed.kind != "unused" { |
| 70 | + // "none" → nothing unused; "error" → agda-unused could not analyse |
| 71 | + // (surfaced via `kind`); neither yields findings. |
| 72 | + continue; |
| 73 | + } |
| 74 | + for (file, line, desc) in parse_findings(&parsed.message) { |
| 75 | + let message = if desc.is_empty() { |
| 76 | + "unused code".to_string() |
| 77 | + } else { |
| 78 | + desc |
| 79 | + }; |
| 80 | + if seen.insert((file.clone(), line, message.clone())) { |
| 81 | + outcome.diagnostics.push(Diagnostic { |
| 82 | + rule: RULE_NAME.to_string(), |
| 83 | + severity: Severity::Warn, |
| 84 | + file, |
| 85 | + message, |
| 86 | + line, |
| 87 | + }); |
| 88 | + } |
| 89 | + } |
| 90 | + } |
| 91 | + Ok(outcome) |
| 92 | +} |
| 93 | + |
| 94 | +/// Invoke `agda-unused` once on `root`. `Ok(None)` iff the binary is absent. |
| 95 | +fn run_one(root: &Path, include_root: &Path) -> Result<Option<UnusedJson>> { |
| 96 | + let output = Command::new("agda-unused") |
| 97 | + .arg(root) |
| 98 | + .arg("--global") |
| 99 | + .arg("--json") |
| 100 | + .arg("-i") |
| 101 | + .arg(include_root) |
| 102 | + .output(); |
| 103 | + |
| 104 | + match output { |
| 105 | + Ok(out) => { |
| 106 | + let mut combined = String::from_utf8_lossy(&out.stdout).into_owned(); |
| 107 | + combined.push_str(&String::from_utf8_lossy(&out.stderr)); |
| 108 | + let json = extract_json(&combined).with_context(|| { |
| 109 | + format!( |
| 110 | + "no JSON object in agda-unused output for {}", |
| 111 | + root.display() |
| 112 | + ) |
| 113 | + })?; |
| 114 | + let parsed: UnusedJson = serde_json::from_str(json) |
| 115 | + .with_context(|| format!("parsing agda-unused JSON for {}", root.display()))?; |
| 116 | + Ok(Some(parsed)) |
| 117 | + } |
| 118 | + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), |
| 119 | + Err(e) => Err(e.into()), |
| 120 | + } |
| 121 | +} |
| 122 | + |
| 123 | +/// Extract the outermost `{ … }` JSON object from `s` (tolerant of any |
| 124 | +/// non-JSON preamble the tool might print before the object). |
| 125 | +fn extract_json(s: &str) -> Option<&str> { |
| 126 | + let start = s.find('{')?; |
| 127 | + let end = s.rfind('}')?; |
| 128 | + if end >= start { |
| 129 | + Some(&s[start..=end]) |
| 130 | + } else { |
| 131 | + None |
| 132 | + } |
| 133 | +} |
| 134 | + |
| 135 | +/// Parse the findings out of an `agda-unused` `message`. Each finding is a |
| 136 | +/// location line (`…/File.agda:line,col-col`) followed by an indented |
| 137 | +/// description line (`<category> '<name>'`). |
| 138 | +fn parse_findings(message: &str) -> Vec<(PathBuf, Option<usize>, String)> { |
| 139 | + let lines: Vec<&str> = message.lines().collect(); |
| 140 | + let mut out = Vec::new(); |
| 141 | + let mut i = 0; |
| 142 | + while i < lines.len() { |
| 143 | + if let Some((file, line)) = parse_location(lines[i]) { |
| 144 | + // The description is the next non-empty line that is not itself a |
| 145 | + // location (indented `<category> '<name>'`). |
| 146 | + let mut desc = String::new(); |
| 147 | + if i + 1 < lines.len() { |
| 148 | + let next = lines[i + 1].trim(); |
| 149 | + if !next.is_empty() && parse_location(lines[i + 1]).is_none() { |
| 150 | + desc = next.to_string(); |
| 151 | + i += 1; |
| 152 | + } |
| 153 | + } |
| 154 | + out.push((file, line, desc)); |
| 155 | + } |
| 156 | + i += 1; |
| 157 | + } |
| 158 | + out |
| 159 | +} |
| 160 | + |
| 161 | +/// Parse a location line `…/File.agda:line,col-col` into the file path and the |
| 162 | +/// 1-based line number. Tolerant: the trailing column/range part is optional. |
| 163 | +fn parse_location(line: &str) -> Option<(PathBuf, Option<usize>)> { |
| 164 | + let trimmed = line.trim(); |
| 165 | + // Anchor on the `.agda:` boundary so paths containing `:` (unlikely) or |
| 166 | + // colons elsewhere don't confuse the split. |
| 167 | + let marker = ".agda:"; |
| 168 | + let idx = trimmed.rfind(marker)?; |
| 169 | + let file = PathBuf::from(&trimmed[..idx + ".agda".len()]); |
| 170 | + let rest = &trimmed[idx + marker.len()..]; |
| 171 | + // `rest` looks like `12,3-8` or `12,3-13,5`; take the leading integer. |
| 172 | + let lineno = rest |
| 173 | + .split(|c: char| !c.is_ascii_digit()) |
| 174 | + .find(|s| !s.is_empty()) |
| 175 | + .and_then(|s| s.parse::<usize>().ok()); |
| 176 | + Some((file, lineno)) |
| 177 | +} |
| 178 | + |
| 179 | +#[cfg(test)] |
| 180 | +mod tests { |
| 181 | + use super::*; |
| 182 | + |
| 183 | + #[test] |
| 184 | + fn absent_binary_degrades_gracefully() { |
| 185 | + // `run_one` against a guaranteed-absent binary name is exercised |
| 186 | + // indirectly: find_unused with a bogus PATH can't be forced here, so |
| 187 | + // we assert the parser/JSON layers instead. The not-found path is the |
| 188 | + // `Ok(None)` arm of `run_one` (see also crate::agda). |
| 189 | + let o = UnusedOutcome::default(); |
| 190 | + assert!(!o.available); |
| 191 | + assert!(o.diagnostics.is_empty()); |
| 192 | + } |
| 193 | + |
| 194 | + #[test] |
| 195 | + fn extract_json_finds_object_amid_noise() { |
| 196 | + assert_eq!( |
| 197 | + extract_json("preamble {\"type\":\"none\"} trailing"), |
| 198 | + Some("{\"type\":\"none\"}") |
| 199 | + ); |
| 200 | + assert_eq!(extract_json("no object here"), None); |
| 201 | + } |
| 202 | + |
| 203 | + #[test] |
| 204 | + fn parse_location_reads_path_and_line() { |
| 205 | + let (f, l) = parse_location("/home/u/proj/Foo/Bar.agda:12,3-8").unwrap(); |
| 206 | + assert_eq!(f, PathBuf::from("/home/u/proj/Foo/Bar.agda")); |
| 207 | + assert_eq!(l, Some(12)); |
| 208 | + } |
| 209 | + |
| 210 | + #[test] |
| 211 | + fn parse_location_tolerates_line_range() { |
| 212 | + let (_f, l) = parse_location("X.agda:7,1-9,4").unwrap(); |
| 213 | + assert_eq!(l, Some(7)); |
| 214 | + } |
| 215 | + |
| 216 | + #[test] |
| 217 | + fn parse_location_rejects_non_location() { |
| 218 | + assert!(parse_location(" unused import 'Foo'").is_none()); |
| 219 | + } |
| 220 | + |
| 221 | + #[test] |
| 222 | + fn parse_findings_pairs_location_with_description() { |
| 223 | + let msg = "/p/Main.agda:3,1-19\n unused import 'Unused'\n"; |
| 224 | + let found = parse_findings(msg); |
| 225 | + assert_eq!(found.len(), 1); |
| 226 | + assert_eq!(found[0].0, PathBuf::from("/p/Main.agda")); |
| 227 | + assert_eq!(found[0].1, Some(3)); |
| 228 | + assert_eq!(found[0].2, "unused import 'Unused'"); |
| 229 | + } |
| 230 | +} |
0 commit comments