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