Skip to content

Commit 54106d9

Browse files
committed
feat(lint): unused-import via agda-unused shell-out (WIP: live parser validation pending)
Adds `src/unused.rs`, an `agda.rs`-style project-level shell-out to the external `agda-unused` analyser, re-emitting its findings as `unused-import` warn diagnostics (spec §Linter rules). Opt-in behind `scan --unused`; degrades gracefully (with a note) when the binary is absent. - Parses agda-unused's `--json` wrapper `{type,message}` and the findings text in `message` (`/path/File.agda:line,col-col` + `<category> 'name'`). - `merge_unused` attributes findings to per-file reports by canonical path. - `lib.rs` re-exports `find_unused` / `UnusedOutcome`. - `dist-newstyle/` gitignored (local agda-unused build output). - Fixture `tests/fixtures/unused/{Main,Helper}.agda` (a genuine unused import). Compiles; 6 parser unit tests pass against agda-unused's documented format. The message-format parser is still to be validated against live agda-unused output (binary building locally) before this PR leaves draft. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019GiSiEfgZCte35dyykgBHs
1 parent 054d54d commit 54106d9

6 files changed

Lines changed: 303 additions & 9 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# Rust build artefacts
22
target/
33

4+
# Haskell / cabal build artefacts (from building agda-unused locally)
5+
dist-newstyle/
6+
47
# Editor / tooling backups
58
*.bak
69
*~

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub mod hash;
1212
pub mod lint;
1313
pub mod proven;
1414
pub mod timestamp;
15+
pub mod unused;
1516
pub mod watcher;
1617
pub mod workspace;
1718

@@ -21,4 +22,5 @@ pub use diagnostic::{Diagnostic, LintReport, Severity};
2122
pub use event::{Event, EventKind};
2223
pub use graph::{build as build_graph, ImportGraph};
2324
pub use lint::{default_rules, rules_with_config, run_lints, LintRule, RuleConfig};
25+
pub use unused::{find_unused, UnusedOutcome};
2426
pub use workspace::{StaleEntry, State, Workspace};

src/main.rs

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
use anyhow::{Context, Result};
22
use arghda_core::lint::LintContext;
33
use arghda_core::{
4-
build_dag, check_file, default_rules, event, graph, rules_with_config, run_lints, watcher,
5-
LintRule, RuleConfig, State, Workspace,
4+
build_dag, check_file, default_rules, event, find_unused, graph, rules_with_config, run_lints,
5+
watcher, Diagnostic, LintReport, LintRule, RuleConfig, State, Workspace,
66
};
77
use clap::{Parser, Subcommand};
8+
use std::collections::HashMap;
89
use std::path::{Path, PathBuf};
910
use std::time::Duration;
1011
use walkdir::WalkDir;
@@ -39,6 +40,11 @@ enum Cmd {
3940
/// (`unpinned-headline` rule). Defaults to the spec pattern.
4041
#[arg(long)]
4142
headline_pattern: Option<String>,
43+
/// Also run the external `agda-unused` analyser and re-emit its
44+
/// findings as `unused-import` warnings (requires `agda-unused` on
45+
/// PATH; skipped with a note if absent).
46+
#[arg(long)]
47+
unused: bool,
4248
/// Emit the report as JSON instead of human-readable text.
4349
#[arg(long)]
4450
json: bool,
@@ -101,8 +107,9 @@ fn main() -> Result<()> {
101107
path,
102108
entry,
103109
headline_pattern,
110+
unused,
104111
json,
105-
} => scan(&path, &entry, headline_pattern.as_deref(), json)?,
112+
} => scan(&path, &entry, headline_pattern.as_deref(), unused, json)?,
106113
Cmd::Check {
107114
file,
108115
include_root,
@@ -142,6 +149,7 @@ fn scan(
142149
include_root: &Path,
143150
entry: &[PathBuf],
144151
headline_pattern: Option<&str>,
152+
unused: bool,
145153
json: bool,
146154
) -> Result<()> {
147155
let (roots, rules) = resolve_roots_and_rules(include_root, entry, headline_pattern)?;
@@ -151,9 +159,6 @@ fn scan(
151159
};
152160

153161
let mut reports = Vec::new();
154-
let mut hard_blocks = 0usize;
155-
let mut warns = 0usize;
156-
157162
for entry in WalkDir::new(include_root)
158163
.into_iter()
159164
.filter_map(|e| e.ok())
@@ -164,17 +169,34 @@ fn scan(
164169
}
165170
let report =
166171
run_lints(path, &ctx, &rules).with_context(|| format!("linting {}", path.display()))?;
167-
hard_blocks += report.hard_blocks().count();
168-
warns += report.warns().count();
169172
reports.push(report);
170173
}
174+
let files_scanned = reports.len();
175+
176+
// Optional project-level unused-code pass via the external `agda-unused`.
177+
if unused {
178+
let outcome = find_unused(include_root, &roots)?;
179+
if !outcome.available {
180+
eprintln!("note: `agda-unused` not found on PATH; skipping unused-import findings");
181+
} else {
182+
if outcome.kind.as_deref() == Some("error") {
183+
eprintln!(
184+
"note: agda-unused reported an analysis error; unused-import findings may be incomplete"
185+
);
186+
}
187+
merge_unused(&mut reports, outcome.diagnostics);
188+
}
189+
}
190+
191+
let hard_blocks: usize = reports.iter().map(|r| r.hard_blocks().count()).sum();
192+
let warns: usize = reports.iter().map(|r| r.warns().count()).sum();
171193

172194
if json {
173195
let payload = serde_json::json!({
174196
"version": "0.1",
175197
"include_root": include_root,
176198
"entry_modules": roots,
177-
"files_scanned": reports.len(),
199+
"files_scanned": files_scanned,
178200
"hard_blocks": hard_blocks,
179201
"warns": warns,
180202
"reports": reports,
@@ -200,6 +222,32 @@ fn scan(
200222
Ok(())
201223
}
202224

225+
/// Attribute `agda-unused` findings to the scanned per-file reports by
226+
/// canonical path. A finding for a file that was not scanned (rare) gets its
227+
/// own report so nothing is silently dropped.
228+
fn merge_unused(reports: &mut Vec<LintReport>, diags: Vec<Diagnostic>) {
229+
let mut by_canon: HashMap<PathBuf, usize> = HashMap::new();
230+
for (idx, r) in reports.iter().enumerate() {
231+
if let Ok(c) = std::fs::canonicalize(&r.file) {
232+
by_canon.insert(c, idx);
233+
}
234+
}
235+
for d in diags {
236+
let canon = std::fs::canonicalize(&d.file).ok();
237+
if let Some(idx) = canon.as_ref().and_then(|c| by_canon.get(c).copied()) {
238+
reports[idx].push(d);
239+
} else {
240+
let idx = reports.len();
241+
if let Some(c) = canon {
242+
by_canon.insert(c, idx);
243+
}
244+
let mut r = LintReport::new(d.file.clone());
245+
r.push(d);
246+
reports.push(r);
247+
}
248+
}
249+
}
250+
203251
fn check(file: &Path, include_root: Option<&Path>, json: bool) -> Result<()> {
204252
if !file.is_file() {
205253
anyhow::bail!("file not found: {}", file.display());

src/unused.rs

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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+
}

tests/fixtures/unused/Helper.agda

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module Helper where
2+
3+
-- A trivial, dependency-free definition. Nothing references it, so a
4+
-- whole-project (`--global`) agda-unused pass should also flag it.
5+
helper : Set₁
6+
helper = Set

tests/fixtures/unused/Main.agda

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module Main where
2+
3+
-- Imports Helper but uses nothing from it: a genuine unused import, which is
4+
-- exactly what `agda-unused` should report (re-emitted as `unused-import`).
5+
open import Helper

0 commit comments

Comments
 (0)