Skip to main content

spec_driven_docs/gates/
no_self_narration.rs

1//! Gate: a document states what is true now.
2//!
3//! This reports the markers that narrate how a document got that way —
4//! "formerly", "used to be", "this replaces", "inherited from". Code is
5//! stripped before matching, because a document stating the rule quotes the
6//! words it forbids, and both code constructs are stripped on `CommonMark`'s
7//! terms: a fence belongs to the container it opened in and ends with it,
8//! and a code span pairs equal backtick runs but never crosses a blank line,
9//! so prose is judged one paragraph at a time. Decision records are excluded
10//! by wiring — history is their job.
11
12use crate::domain::finding::Finding;
13use crate::domain::rule_id::RuleId;
14use crate::gates::{GateCtx, GateResult, Violation, read_text};
15
16/// The rules this gate can cite.
17pub const CITES: &[RuleId] = &[RuleId::DocumentStatesThePresent];
18
19const MARKERS: &[&str] = &["formerly", "used to be", "this replaces", "inherited from"];
20
21/// Blank every closed code span, preserving newlines so line numbers
22/// survive; an unclosed run is literal text and stays.
23fn strip_spans(text: &str) -> String {
24    let chars: Vec<char> = text.chars().collect();
25    let mut out = String::with_capacity(text.len());
26    let mut i = 0;
27    while i < chars.len() {
28        if chars[i] != '`' {
29            out.push(chars[i]);
30            i += 1;
31            continue;
32        }
33        let mut j = i;
34        while j < chars.len() && chars[j] == '`' {
35            j += 1;
36        }
37        let run = j - i;
38        let mut end = None;
39        let mut k = j;
40        while k < chars.len() {
41            if chars[k] == '`' {
42                let mut m = k;
43                while m < chars.len() && chars[m] == '`' {
44                    m += 1;
45                }
46                if m - k == run {
47                    end = Some(m);
48                    break;
49                }
50                k = m;
51            } else {
52                k += 1;
53            }
54        }
55        if let Some(end) = end {
56            for &ch in &chars[i..end] {
57                out.push(if ch == '\n' { '\n' } else { ' ' });
58            }
59            i = end;
60        } else {
61            for _ in 0..run {
62                out.push('`');
63            }
64            i = j;
65        }
66    }
67    out
68}
69
70struct Paragraph {
71    prose: Vec<String>,
72    lines: Vec<usize>,
73    raw: Vec<String>,
74}
75
76fn report(paragraph: &mut Paragraph, file: &str, violations: &mut Vec<Violation>) {
77    if paragraph.prose.is_empty() {
78        return;
79    }
80    let mut text = String::new();
81    for line in &paragraph.prose {
82        text.push_str(line);
83        text.push('\n');
84    }
85    let stripped = strip_spans(&text);
86    for (index, line) in stripped.lines().enumerate().take(paragraph.prose.len()) {
87        let lower = line.to_lowercase();
88        if MARKERS.iter().any(|marker| lower.contains(marker)) {
89            violations.push(Violation::Finding(Finding::on_line(
90                RuleId::DocumentStatesThePresent,
91                file,
92                paragraph.lines[index],
93                paragraph.raw[index].clone(),
94            )));
95        }
96    }
97    paragraph.prose.clear();
98    paragraph.lines.clear();
99    paragraph.raw.clear();
100}
101
102struct Fence {
103    delimiter: char,
104    length: usize,
105    quotes: usize,
106    indent: usize,
107}
108
109fn container(line: &str) -> (usize, usize, &str) {
110    let mut rest = line;
111    let mut quotes = 0;
112    loop {
113        let trimmed = rest.trim_start_matches([' ', '\t']);
114        if let Some(after) = trimmed.strip_prefix('>') {
115            quotes += 1;
116            rest = after.strip_prefix([' ', '\t']).unwrap_or(after);
117        } else {
118            break;
119        }
120    }
121    let content = rest.trim_start_matches([' ', '\t']);
122    let indent = rest.len() - content.len();
123    (quotes, indent, content)
124}
125
126fn fence_delimiter(content: &str) -> Option<(char, usize)> {
127    let first = content.chars().next()?;
128    if first != '`' && first != '~' {
129        return None;
130    }
131    let length = content.chars().take_while(|&c| c == first).count();
132    (length >= 3).then_some((first, length))
133}
134
135fn judge(file: &str, text: &str, violations: &mut Vec<Violation>) {
136    let mut fence: Option<Fence> = None;
137    let mut paragraph = Paragraph {
138        prose: Vec::new(),
139        lines: Vec::new(),
140        raw: Vec::new(),
141    };
142
143    for (number, raw) in text.lines().enumerate() {
144        let (quotes, indent, content) = container(raw);
145
146        if let Some(open) = &fence
147            && !content.is_empty()
148            && (quotes < open.quotes || indent < open.indent)
149        {
150            fence = None;
151        }
152
153        if let Some((delimiter, length)) = fence_delimiter(content) {
154            match &fence {
155                None => {
156                    fence = Some(Fence {
157                        delimiter,
158                        length,
159                        quotes,
160                        indent,
161                    });
162                    continue;
163                }
164                Some(open)
165                    if delimiter == open.delimiter
166                        && length >= open.length
167                        && content.trim_start_matches(delimiter).trim_end().is_empty() =>
168                {
169                    fence = None;
170                    continue;
171                }
172                Some(_) => continue,
173            }
174        }
175        if fence.is_some() {
176            continue;
177        }
178        if content.is_empty() {
179            report(&mut paragraph, file, violations);
180            continue;
181        }
182        paragraph.prose.push(content.to_string());
183        paragraph.lines.push(number + 1);
184        paragraph.raw.push(raw.to_string());
185    }
186    report(&mut paragraph, file, violations);
187}
188
189/// Judge every file pre-commit passed.
190///
191/// # Errors
192///
193/// [`crate::gates::GateError::Io`] when a file cannot be read.
194pub fn run(ctx: &GateCtx, files: &[String]) -> GateResult {
195    let mut violations = Vec::new();
196    for file in files {
197        let text = read_text(ctx, file)?;
198        judge(file, &text, &mut violations);
199    }
200    Ok(violations)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    fn run_on(text: &str) -> Vec<String> {
208        let dir = tempfile::tempdir().unwrap();
209        std::fs::write(dir.path().join("doc.md"), text).unwrap();
210        let ctx = GateCtx::new(dir.path().to_str().unwrap());
211        run(&ctx, &["doc.md".to_string()])
212            .unwrap()
213            .iter()
214            .map(ToString::to_string)
215            .collect()
216    }
217
218    #[test]
219    fn accepts_present_tense_prose() {
220        assert!(run_on("# Present\n\nThe rule applies now.\n").is_empty());
221    }
222
223    #[test]
224    fn rejects_narration_with_the_offending_line() {
225        let out = run_on("# History\n\nThis replaces an older rule.\n");
226        assert_eq!(
227            out,
228            vec![
229                "FAIL docs-format:document-states-the-present doc.md:3: This replaces an older rule."
230                    .to_string()
231            ]
232        );
233    }
234
235    #[test]
236    fn a_fenced_quotation_of_the_markers_is_code() {
237        assert!(run_on("# Doc\n\n```text\nformerly a rule\n```\n").is_empty());
238    }
239
240    #[test]
241    fn a_code_span_quoting_a_marker_is_code() {
242        assert!(run_on("The gate rejects `formerly` in prose.\n").is_empty());
243    }
244
245    #[test]
246    fn a_span_cannot_cross_a_blank_line() {
247        let out = run_on("An odd `backtick here.\n\nformerly narrated` prose.\n");
248        assert_eq!(out.len(), 1);
249        assert!(out[0].contains("doc.md:3"));
250    }
251
252    #[test]
253    fn a_fence_left_open_ends_with_its_container() {
254        let out = run_on("> ```\n> code\n\nformerly outside the quote.\n");
255        assert_eq!(out.len(), 1);
256        assert!(out[0].contains("doc.md:4"));
257    }
258
259    #[test]
260    fn matching_is_case_insensitive() {
261        assert_eq!(run_on("Formerly this was different.\n").len(), 1);
262    }
263}