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