Skip to main content

drep/docs/
mod.rs

1//! Rule-based markdown checks. No LLM, no network, no configuration.
2//!
3//! Ten checks over the text of a markdown file, run by `drep lint-docs`. This
4//! module deliberately imports nothing from `llm`, `config` or `analysis`
5//! beyond the [`Finding`] vocabulary: `lint-docs` runs on every commit, and
6//! the 1.x equivalent paid 190 ms of startup for a provider chain, a response
7//! cache and a database it never touched.
8//!
9//! Structure:
10//!
11//! - `fence` answers "is this line inside a code fence", once per file, for
12//!   every check that asks. See its module doc for why that is not a per-check
13//!   concern.
14//! - `lines` holds the checks that look at one line in isolation.
15//! - `links` holds the two that need markdown's link grammar.
16//! - `blocks` holds the three that span more than one line.
17//!
18//! The split is by what a check needs to see, not by file size, so a new check
19//! has an obvious home.
20
21mod blocks;
22mod fence;
23mod lines;
24mod links;
25
26use std::path::Path;
27
28use crate::analysis::findings::{Finding, Severity};
29
30/// Longest line drep will accept outside a code fence.
31///
32/// A fixed number, not a setting. `lint-docs` is report-only unless `--strict`
33/// is passed, so a project that disagrees (this repository does - its
34/// `.markdownlint.json` sets `MD013: false`) runs it report-only and ignores
35/// the line rather than tuning a threshold drep would then have to reconcile
36/// with the project's own linter.
37pub const LONG_LINE_MAX: usize = 120;
38
39/// Consecutive blank lines tolerated outside a code fence.
40///
41/// The check fires on the run that exceeds this, i.e. at three blanks.
42pub const BLANK_RUN_MAX: usize = 2;
43
44/// The ten checks.
45///
46/// The wire names are the `type=` strings the 1.x Python analyzer emitted, so
47/// a user who scripted against `drep lint-docs` output does not have to relearn
48/// them. [`Check::as_str`] is the only place a name is written.
49///
50/// [`Check::ALL`] is what the tests iterate, and it is a hand-maintained list:
51/// Rust cannot enumerate an enum without a derive macro, so nothing makes a
52/// new variant appear in it. What does force the author's hand is that the
53/// three `match self` methods below are exhaustive - a new variant fails to
54/// compile until all three are extended, and `ALL` sits immediately above
55/// them. Treat adding to `ALL` as part of adding a variant; a check missing
56/// from it silently drops out of every test in this module.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
58pub enum Check {
59    BareUrl,
60    EmptyHeading,
61    LinkSyntaxInvalid,
62    LongLine,
63    MissingSpaceAfterHeading,
64    MultipleBlankLines,
65    TabCharacter,
66    TrailingBlankLines,
67    TrailingWhitespace,
68    UnclosedCodeFence,
69}
70
71impl Check {
72    /// Every check. The one place the vocabulary is listed.
73    pub const ALL: [Check; 10] = [
74        Check::BareUrl,
75        Check::EmptyHeading,
76        Check::LinkSyntaxInvalid,
77        Check::LongLine,
78        Check::MissingSpaceAfterHeading,
79        Check::MultipleBlankLines,
80        Check::TabCharacter,
81        Check::TrailingBlankLines,
82        Check::TrailingWhitespace,
83        Check::UnclosedCodeFence,
84    ];
85
86    /// The wire name, as it appears in a finding's `kind`.
87    pub const fn as_str(self) -> &'static str {
88        match self {
89            Check::BareUrl => "bare_url",
90            Check::EmptyHeading => "empty_heading",
91            Check::LinkSyntaxInvalid => "link_syntax_invalid",
92            Check::LongLine => "long_line",
93            Check::MissingSpaceAfterHeading => "missing_space_after_heading",
94            Check::MultipleBlankLines => "multiple_blank_lines",
95            Check::TabCharacter => "tab_character",
96            Check::TrailingBlankLines => "trailing_blank_lines",
97            Check::TrailingWhitespace => "trailing_whitespace",
98            Check::UnclosedCodeFence => "unclosed_code_fence",
99        }
100    }
101
102    /// How badly this check's subject breaks the document.
103    ///
104    /// One rule decides all ten: **does it change how the document renders?**
105    ///
106    /// - [`Severity::Error`] - the rest of the file renders as something else.
107    ///   Only an unclosed fence does that, and it does it to every line below
108    ///   itself.
109    /// - [`Severity::Warning`] - that line renders wrong. A heading that is not
110    ///   a heading, a link that is not a link.
111    /// - [`Severity::Info`] - renders identically; this is hygiene.
112    ///
113    /// The rule matters because `drep lint-docs --strict` and `--fail-on`
114    /// downstream both gate on it, and "whitespace blocks a commit" is the
115    /// calibration failure that makes a gate get switched off.
116    pub const fn severity(self) -> Severity {
117        match self {
118            Check::UnclosedCodeFence => Severity::Error,
119            Check::EmptyHeading | Check::MissingSpaceAfterHeading | Check::LinkSyntaxInvalid => {
120                Severity::Warning
121            }
122            Check::BareUrl
123            | Check::LongLine
124            | Check::MultipleBlankLines
125            | Check::TabCharacter
126            | Check::TrailingBlankLines
127            | Check::TrailingWhitespace => Severity::Info,
128        }
129    }
130
131    /// What to do about it. Advice, never a literal replacement.
132    ///
133    /// 1.x carried a `replacement` field that was sometimes a rewritten line
134    /// and sometimes a sentence of prose, because a draft-PR autofix consumed
135    /// it. 2.0 has no autofix, so a field that is a literal half the time is a
136    /// trap for whoever next tries to apply one.
137    pub const fn suggestion(self) -> &'static str {
138        match self {
139            Check::BareUrl => "wrap it as [text](url)",
140            Check::EmptyHeading => "give the heading text, or delete the line",
141            Check::LinkSyntaxInvalid => "balance the brackets: [text](url)",
142            Check::LongLine => "wrap or rephrase",
143            Check::MissingSpaceAfterHeading => "put a space after the `#`s",
144            Check::MultipleBlankLines => "reduce to one blank line",
145            Check::TabCharacter => "replace tabs with spaces",
146            Check::TrailingBlankLines => "remove the blank line(s) at end of file",
147            Check::TrailingWhitespace => "remove the trailing whitespace",
148            Check::UnclosedCodeFence => "close it with ```",
149        }
150    }
151}
152
153/// One line, and where it sits.
154///
155/// Deliberately does **not** carry the line's characters. Every column drep
156/// reports is a character offset rather than a byte offset - a heading under a
157/// line containing an em dash must not have its column shifted by two - so the
158/// checks do need a `[char]`, but only for the line being examined. Holding one
159/// `Vec<char>` per line meant an allocation per line of every file and 43% of
160/// the analysis pass; [`analyze`] now fills a single reused buffer instead.
161/// [`blocks`] needs no characters at all.
162pub(crate) struct Line<'a> {
163    /// 1-based line number, as reported.
164    pub number: u32,
165    /// The line as written, without its terminator.
166    pub text: &'a str,
167    /// True iff a fence delimiter or a line between two of them.
168    pub in_fence: bool,
169}
170
171/// Build a [`Finding`] for `check` at a position.
172///
173/// Central so that the kind/severity/suggestion triple is never assembled by
174/// hand at a check site, where one of the three can quietly disagree with
175/// [`Check`].
176pub(crate) fn finding(
177    check: Check,
178    file_path: &str,
179    line: u32,
180    column: u32,
181    message: String,
182) -> Finding {
183    Finding::deterministic(
184        check.as_str().to_owned(),
185        check.severity(),
186        file_path.to_owned(),
187        line,
188        Some(column),
189        message,
190        Some(check.suggestion().to_owned()),
191    )
192}
193
194/// Run every check over `content`, reporting against `path`.
195///
196/// Findings come back sorted by position, then by check name, so the output of
197/// two runs over the same file is byte-identical and a reader follows the file
198/// top to bottom. The checks themselves run in whatever order is convenient -
199/// grouping the output by check, as the 1.x implementation's append order did,
200/// makes a reader jump around the file.
201pub fn analyze(path: &Path, content: &str) -> Vec<Finding> {
202    let file_path = path.to_string_lossy().into_owned();
203    let raw: Vec<&str> = content.lines().collect();
204    let fences = fence::Fences::scan(&raw);
205
206    let lines: Vec<Line<'_>> = raw
207        .iter()
208        .zip(fences.mask())
209        .enumerate()
210        .map(|(index, (text, in_fence))| Line {
211            number: index as u32 + 1,
212            text,
213            in_fence: *in_fence,
214        })
215        .collect();
216
217    // Two buffers for the whole file rather than one allocation per line:
218    // `chars` holds the line under examination, `scratch` the blanked copy the
219    // link checks work on. Both are cleared and refilled, so peak memory is the
220    // longest line rather than the file.
221    let mut findings = Vec::new();
222    let mut chars: Vec<char> = Vec::new();
223    let mut scratch: Vec<char> = Vec::new();
224    for line in &lines {
225        chars.clear();
226        chars.extend(line.text.chars());
227        lines::check(line, &chars, &file_path, &mut findings);
228        links::check(line, &chars, &mut scratch, &file_path, &mut findings);
229    }
230    blocks::check(&lines, &fences, &file_path, &mut findings);
231
232    findings.sort_by(|a, b| (a.line, a.column, &a.kind).cmp(&(b.line, b.column, &b.kind)));
233    findings
234}
235
236#[cfg(test)]
237mod tests;