Skip to main content

drep/cli/lint_docs/
mod.rs

1//! `drep lint-docs` - rule-based markdown checks. No LLM, no network.
2//!
3//! This command runs on every commit, so its startup path is load-bearing. It
4//! touches [`crate::docs`], [`crate::files`] and - in `--staged` mode only -
5//! [`crate::diff`], and nothing else: no config file is read, no provider
6//! chain is built, no response cache is opened. The 1.x equivalent imported
7//! its workflows at module scope and paid 190 ms of sqlalchemy and GitPython
8//! on every commit for a command that needs neither.
9//!
10//! Two exit rules, and they are independent:
11//!
12//! - A file the user named that drep could not analyze exits **2**, whether or
13//!   not `--strict` was passed. "Report-only" governs *findings*; a file that
14//!   went unread is not a finding, it is the absence of analysis, and that is
15//!   the one thing drep never reports as clean.
16//! - Findings exit **1** only at or above `--fail-on`, which is unset by
17//!   default. The default is report-only because the checks include whitespace
18//!   and line length, and a gate that blocks a commit over a trailing space is
19//!   a gate that gets switched off. `--strict` is the shorthand for
20//!   `--fail-on info`: one mechanism, asked at two thresholds.
21
22pub(crate) mod render;
23
24use std::collections::BTreeMap;
25use std::path::{Path, PathBuf};
26
27use anyhow::Result;
28use clap::{ArgGroup, Args};
29
30use crate::Exit;
31use crate::analysis::findings::{self, Finding, Severity};
32use crate::analysis::result::FailureReason;
33use crate::cli::severity_parser;
34use crate::files;
35
36#[derive(Debug, Args)]
37// Same shape as `check`'s input group, and stated once for the same reason:
38// paired `conflicts_with` attributes say it from each side and can drift.
39// Deliberately not `required`: bare `drep lint-docs` means "this tree".
40#[command(group(ArgGroup::new("docs-input").args(["paths", "staged"]).multiple(false)))]
41pub struct LintDocsArgs {
42    /// Markdown files or directories. Defaults to the current directory.
43    #[arg(value_name = "PATH")]
44    pub paths: Vec<PathBuf>,
45
46    /// Lint the markdown staged for commit. For a pre-commit hook.
47    ///
48    /// The hook `drep init` writes uses this. Running the command bare would
49    /// also work - it is ~10 ms over a repository this size - but it reports
50    /// findings in documents the commit never touched, and per-commit noise
51    /// about someone else's file is how a report-only gate gets switched off.
52    #[arg(long)]
53    pub staged: bool,
54
55    /// Exit non-zero when any check fires. Shorthand for `--fail-on info`.
56    #[arg(long, conflicts_with = "fail_on")]
57    pub strict: bool,
58
59    /// Exit non-zero when a check at or above this severity fires.
60    ///
61    /// Severity here answers one question: does the finding change how the
62    /// document renders? An unclosed fence does - everything below it becomes
63    /// code - so it alone is `error`. A malformed heading or link renders
64    /// wrong, so those are `warning`. Whitespace and line length render
65    /// identically, so they are `info`. `--fail-on error` is therefore the
66    /// calibration a hook wants: it blocks on the defect that breaks the
67    /// document and stays quiet about trailing spaces.
68    #[arg(long, value_name = "SEVERITY", value_parser = severity_parser())]
69    pub fail_on: Option<Severity>,
70}
71
72impl LintDocsArgs {
73    /// The severity at or above which a finding fails the run, if any.
74    ///
75    /// One place resolves `--strict` to a threshold, so the gate never has to
76    /// know that two flags exist. `--strict` is `--fail-on info` because
77    /// `Info` is the bottom of the vocabulary: every finding is at or above
78    /// it, which is precisely what "block on anything" means.
79    pub fn threshold(&self) -> Option<Severity> {
80        self.fail_on.or(self.strict.then_some(Severity::Info))
81    }
82}
83
84/// Everything one `lint-docs` run produced.
85pub struct LintOutcome {
86    /// Every finding, sorted by file then by position.
87    pub findings: Vec<Finding>,
88    /// Files the user named that drep could not analyze.
89    pub failures: BTreeMap<PathBuf, FailureReason>,
90    /// The verdict. On the outcome rather than beside it, so the renderer and
91    /// the process cannot disagree - the mistake `check` made and fixed.
92    pub exit: Exit,
93    /// What the threshold did to these findings.
94    ///
95    /// Here for the same reason `exit` is: the footer has to say whether the
96    /// findings on screen blocked the run, and computing that a second time in
97    /// the renderer is the identical mistake one field up. It is not
98    /// derivable from `exit` either - a run with an unreadable file exits
99    /// `Unanalyzed` whether or not its findings also crossed the threshold.
100    pub gating: Gating,
101}
102
103/// What a run's threshold did to its findings.
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum Gating {
106    /// No threshold: findings are reported and nothing blocks.
107    ReportOnly,
108    /// A threshold was in force and no finding reached it.
109    NoneReached(Severity),
110    /// A threshold was in force and at least one finding reached it.
111    Blocked,
112}
113
114/// One `lint-docs` invocation.
115///
116/// `async` for exactly one reason, and it is not concurrency: `--staged` asks
117/// git which documents this commit touches. Every other mode reads files and
118/// runs pure checks with nothing to await.
119pub async fn run(args: &LintDocsArgs, root: &Path) -> Result<Exit> {
120    let outcome = outcome_for(args, root).await?;
121    render::render(&outcome)?;
122    Ok(outcome.exit)
123}
124
125/// The outcome for one invocation, in whichever input mode it names.
126///
127/// `async` for exactly one reason: `--staged` asks git which documents this
128/// commit touches, through [`crate::diff`], which is the single place in the
129/// binary that invokes git. Everything else here is synchronous file reading
130/// and pure checks.
131pub(crate) async fn outcome_for(args: &LintDocsArgs, root: &Path) -> Result<LintOutcome> {
132    if !args.staged {
133        return Ok(analyze(args, root));
134    }
135
136    // Straight to the reader, not through `files::expand_named`: git has
137    // already answered both questions the expander exists to answer - these
138    // paths exist and they are markdown - and the expander resolves an *empty*
139    // list to `root`. That default is what makes bare `drep lint-docs` mean
140    // "this tree", and reusing it here would turn "this commit touches no
141    // markdown" into "lint every document in the repository", on every commit.
142    let staged = crate::diff::staged_files(root, files::is_markdown).await?;
143    Ok(analyze_files(
144        staged.into_iter().map(|p| root.join(p)).collect(),
145        BTreeMap::new(),
146        args.threshold(),
147    ))
148}
149
150/// Resolve the paths, read each file, run the checks, and gate.
151///
152/// Split from [`run`] so a test asserts on the outcome without capturing
153/// stdout, and so the rendering has nothing to decide.
154fn analyze(args: &LintDocsArgs, root: &Path) -> LintOutcome {
155    let mut failures: BTreeMap<PathBuf, FailureReason> = BTreeMap::new();
156    let targets = resolve(&args.paths, root, &mut failures);
157    analyze_files(targets, failures, args.threshold())
158}
159
160/// Read each target, run the checks, and gate.
161///
162/// Takes the failures already collected rather than starting empty: the path
163/// expansion rejects some of what the user named, and those rejections outrank
164/// every finding this function can produce.
165fn analyze_files(
166    targets: Vec<PathBuf>,
167    mut failures: BTreeMap<PathBuf, FailureReason>,
168    threshold: Option<Severity>,
169) -> LintOutcome {
170    let mut findings = Vec::new();
171    for path in targets {
172        match std::fs::read_to_string(&path) {
173            Ok(content) => findings.extend(crate::docs::analyze(&path, &content)),
174            Err(err) => {
175                failures.insert(path, FailureReason::Unreadable(err.to_string()));
176            }
177        }
178    }
179
180    // The output contract is "reads top to bottom, file by file". Sorting by
181    // file here rather than trusting the expander's order keeps that contract
182    // owned by this function; `docs::analyze` has already ordered each file's
183    // own findings, and a stable sort preserves that, so the position key is
184    // not restated.
185    findings.sort_by(|a, b| a.file_path.cmp(&b.file_path));
186
187    let gating = gating(&findings, threshold);
188    let exit = gate(&failures, gating);
189    LintOutcome {
190        findings,
191        failures,
192        exit,
193        gating,
194    }
195}
196
197/// Expand the arguments to markdown files, recording what the user named and
198/// drep will not analyze.
199///
200/// The policy lives in [`files::expand_named`], shared with `check`, so the two
201/// commands cannot disagree about what a named path that resolves to nothing
202/// means.
203fn resolve(
204    paths: &[PathBuf],
205    root: &Path,
206    failures: &mut BTreeMap<PathBuf, FailureReason>,
207) -> Vec<PathBuf> {
208    let files::Expansion { targets, rejected } =
209        files::expand_named(paths, root, files::is_markdown);
210    for (path, why) in rejected {
211        let reason = match why {
212            files::Rejected::Missing => {
213                FailureReason::Unreadable("no such file or directory".to_owned())
214            }
215            files::Rejected::Unanalyzable => {
216                FailureReason::unsupported(&path, files::redirect_hint(&path))
217            }
218        };
219        failures.insert(path, reason);
220    }
221    targets
222}
223
224/// What the threshold did to these findings.
225///
226/// The comparison is `findings::any_at_or_above`, shared with `check`: two
227/// commands in one binary that disagree about which findings a severity
228/// threshold covers are two contracts a hook author has to learn.
229fn gating(findings: &[Finding], threshold: Option<Severity>) -> Gating {
230    match threshold {
231        None => Gating::ReportOnly,
232        Some(threshold) if findings::any_at_or_above(findings, threshold) => Gating::Blocked,
233        Some(threshold) => Gating::NoneReached(threshold),
234    }
235}
236
237/// Failures outrank findings.
238///
239/// The precedence matches `check`'s, deliberately: a run that did not read a
240/// file the user named has to say so even when it also found issues, or they
241/// fix the issues and never learn about the file.
242fn gate(failures: &BTreeMap<PathBuf, FailureReason>, gating: Gating) -> Exit {
243    if !failures.is_empty() {
244        return Exit::Unanalyzed;
245    }
246    match gating {
247        Gating::Blocked => Exit::FoundIssues,
248        Gating::ReportOnly | Gating::NoneReached(_) => Exit::Clean,
249    }
250}
251
252#[cfg(test)]
253mod tests;