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