spec_driven_docs/gates/
no_self_narration.rs1use crate::domain::finding::Finding;
13use crate::domain::rule_id::RuleId;
14use crate::gates::{GateCtx, GateResult, Violation, read_text};
15
16pub const CITES: &[RuleId] = &[RuleId::DocumentStatesThePresent];
18
19const MARKERS: &[&str] = &["formerly", "used to be", "this replaces", "inherited from"];
20
21fn 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 ¶graph.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
189pub 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}