1use regex::Regex;
17
18use crate::camel::to_camel_key;
19use crate::contract::{DocRule, Rule};
20use crate::finding::Finding;
21use crate::registry::Ctx;
22use crate::text_match::{
23 TextFindingInput, TextKind, TextMatchSpec, build_regex_or_panic, build_text_findings,
24 count_bound_problem, match_text,
25};
26use crate::tree::{BlockNode, DocTree, SectionNode};
27
28fn assert_count_bounds(kind: TextKind, specs: &[TextMatchSpec]) {
35 for spec in specs {
36 if let Some(problem) = count_bound_problem(kind, spec) {
37 panic!("{}: {}", problem.id, problem.message);
38 }
39 }
40}
41
42struct LineBuffer {
48 lines: Vec<String>,
49}
50
51impl LineBuffer {
52 fn new() -> Self {
53 Self { lines: Vec::new() }
54 }
55
56 fn place(&mut self, line: u32, text: &str) {
57 if text.is_empty() {
58 return;
59 }
60 for (i, part) in text.split('\n').enumerate() {
61 let Some(idx) = (line as usize + i).checked_sub(1) else {
62 continue;
63 };
64 while self.lines.len() <= idx {
65 self.lines.push(String::new());
66 }
67 let existing = &mut self.lines[idx];
68 if existing.is_empty() {
69 existing.push_str(part);
70 } else {
71 existing.push(' ');
72 existing.push_str(part);
73 }
74 }
75 }
76
77 fn render(&self) -> String {
78 self.lines.join("\n")
79 }
80}
81
82fn place_block(buf: &mut LineBuffer, block: &BlockNode) {
84 match block {
85 BlockNode::Paragraph { text, pos, .. } => buf.place(pos.line, text),
86 BlockNode::List { items, .. } => {
87 for item in items {
88 buf.place(item.pos.line, &item.text);
89 }
90 }
91 BlockNode::Code {
92 value, fenced, pos, ..
93 } => buf.place(pos.line + u32::from(*fenced), value),
94 BlockNode::Table {
95 columns,
96 rows,
97 row_pos,
98 pos,
99 ..
100 } => {
101 buf.place(pos.line, &columns.join(" "));
102 for (i, row) in rows.iter().enumerate() {
103 if let Some(rp) = row_pos.get(i) {
104 buf.place(rp.line, &row.join(" "));
105 }
106 }
107 }
108 }
109}
110
111fn section_scope_text(node: &SectionNode) -> String {
114 fn walk(n: &SectionNode, buf: &mut LineBuffer) {
115 for block in &n.blocks {
116 place_block(buf, block);
117 }
118 for child in &n.sections {
119 walk(child, buf);
120 }
121 }
122 let mut buf = LineBuffer::new();
123 walk(node, &mut buf);
124 buf.render()
125}
126
127fn doc_scope_text(tree: &DocTree) -> String {
129 section_scope_text(&tree.root)
130}
131
132pub(crate) type CompiledSpec = (TextMatchSpec, Option<Regex>);
136
137type Compiled = Vec<CompiledSpec>;
138
139fn compile(specs: Vec<TextMatchSpec>) -> Compiled {
142 specs
143 .into_iter()
144 .map(|spec| {
145 let re = build_regex_or_panic(&spec);
146 (spec, re)
147 })
148 .collect()
149}
150
151struct TextNodeRule {
152 kind: TextKind,
153 id: &'static str,
154 specs: Compiled,
155}
156
157impl Rule for TextNodeRule {
158 fn id(&self) -> &str {
159 self.id
160 }
161
162 fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding> {
163 let scope_key = to_camel_key(&node.name);
164 let text = section_scope_text(node);
165 let mut out = Vec::new();
166 for (spec, re) in &self.specs {
167 out.extend(build_text_findings(
168 TextFindingInput {
169 kind: self.kind,
170 spec,
171 result: match_text(&text, re.as_ref()),
172 scope_key: &scope_key,
173 scope: Some(&node.name),
174 scope_pos: Some(node.pos),
175 },
176 ctx,
177 ));
178 }
179 out
180 }
181}
182
183pub fn requires(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
192 assert_count_bounds(TextKind::Requires, &specs);
193 requires_compiled(compile(specs))
194}
195
196pub(crate) fn requires_compiled(specs: Compiled) -> Box<dyn Rule> {
198 Box::new(TextNodeRule {
199 kind: TextKind::Requires,
200 id: "text/requires",
201 specs,
202 })
203}
204
205pub fn forbids(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
213 assert_count_bounds(TextKind::Forbids, &specs);
214 forbids_compiled(compile(specs))
215}
216
217pub(crate) fn forbids_compiled(specs: Compiled) -> Box<dyn Rule> {
219 Box::new(TextNodeRule {
220 kind: TextKind::Forbids,
221 id: "text/forbids",
222 specs,
223 })
224}
225
226#[derive(Default)]
229pub struct TextRuleSpec {
230 pub requires: Vec<TextMatchSpec>,
232 pub forbids: Vec<TextMatchSpec>,
234}
235
236struct TextDocRule {
237 requires: Compiled,
238 forbids: Compiled,
239}
240
241impl DocRule for TextDocRule {
242 fn id(&self) -> &str {
243 "text/doc"
244 }
245
246 fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding> {
247 let text = doc_scope_text(tree);
248 let mut out = Vec::new();
249 for (spec, re) in &self.requires {
250 out.extend(build_text_findings(
251 TextFindingInput {
252 kind: TextKind::Requires,
253 spec,
254 result: match_text(&text, re.as_ref()),
255 scope_key: "doc",
256 scope: None,
257 scope_pos: None,
258 },
259 ctx,
260 ));
261 }
262 for (spec, re) in &self.forbids {
263 out.extend(build_text_findings(
264 TextFindingInput {
265 kind: TextKind::Forbids,
266 spec,
267 result: match_text(&text, re.as_ref()),
268 scope_key: "doc",
269 scope: None,
270 scope_pos: None,
271 },
272 ctx,
273 ));
274 }
275 out
276 }
277}
278
279pub fn text_rule(spec: TextRuleSpec) -> Box<dyn DocRule> {
286 assert_count_bounds(TextKind::Requires, &spec.requires);
287 assert_count_bounds(TextKind::Forbids, &spec.forbids);
288 text_rule_compiled(compile(spec.requires), compile(spec.forbids))
289}
290
291pub(crate) fn text_rule_compiled(requires: Compiled, forbids: Compiled) -> Box<dyn DocRule> {
293 Box::new(TextDocRule { requires, forbids })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use crate::contract::{Contract, LevelOpts, SectionOpts, section, section_with, sections};
300 use crate::finding::FindingLevel;
301 use crate::validate::validate;
302
303 fn pattern(p: &str) -> TextMatchSpec {
304 TextMatchSpec {
305 pattern: Some(p.into()),
306 ..Default::default()
307 }
308 }
309
310 fn brief(findings: &[Finding]) -> Vec<(&str, FindingLevel, Option<u32>)> {
311 findings
312 .iter()
313 .map(|f| (f.id.as_str(), f.level, f.pos.map(|p| p.line)))
314 .collect()
315 }
316
317 #[test]
320 fn section_requires_miss_pins_the_heading() {
321 let build = || {
322 Contract::new().body(sections(
323 LevelOpts::default(),
324 vec![section_with(
325 "Summary",
326 SectionOpts {
327 rules: vec![requires(vec![pattern("outcome")])],
328 ..Default::default()
329 },
330 )],
331 ))
332 };
333 let pass = "## Summary\n\nThe outcome is recorded here.\n";
334 assert_eq!(validate(pass, &build(), "d.md"), vec![]);
335 let fail = "## Summary\n\nThis decision records the chosen direction.\n";
336 assert_eq!(
337 brief(&validate(fail, &build(), "d.md")),
338 vec![(
339 "text/requires/summary/1tc7itx",
340 FindingLevel::Error,
341 Some(1)
342 )]
343 );
344 }
345
346 #[test]
348 fn doc_forbids_hit_anchors_the_offending_line() {
349 let build = || {
350 Contract::new()
351 .body(sections(LevelOpts::default(), vec![section("Summary")]))
352 .doc_rule(text_rule(TextRuleSpec {
353 forbids: vec![TextMatchSpec {
354 normalize: Some(false),
355 ..pattern("}scripts/")
356 }],
357 ..Default::default()
358 }))
359 };
360 let fail = "## Summary\n\nCallers reach into }scripts/legacy.sh directly.\n";
361 assert_eq!(
362 brief(&validate(fail, &build(), "d.md")),
363 vec![("text/forbids/doc/o9pijh", FindingLevel::Error, Some(3))]
364 );
365 }
366
367 #[test]
370 fn scope_text_reconstruction_is_line_faithful() {
371 let tree = crate::parse::parse_document(
372 "## Top\n\nintro prose\n\n### Child\n\n- item one\n- item two\n\n| A | B |\n| - | - |\n| x | y |\n",
373 );
374 let text = section_scope_text(&tree.root.sections[0]);
375 let lines: Vec<&str> = text.split('\n').collect();
376 assert_eq!(lines[2], "intro prose"); assert_eq!(lines[6], "item one"); assert_eq!(lines[9], "A B"); assert_eq!(lines[11], "x y"); }
381
382 #[test]
383 fn code_text_starts_after_the_fence_and_on_the_line_of_indented_code() {
384 let line_of_todo = |src: &str| {
385 let tree = crate::parse::parse_document(src);
386 let text = section_scope_text(&tree.root.sections[0]);
387 text.split('\n').position(|l| l == "TODO").map(|i| i + 1)
388 };
389 assert_eq!(line_of_todo("## N\n\n```sh\nok\nTODO\n```\n"), Some(5));
390 assert_eq!(line_of_todo("## N\n\n~~~\nTODO\n~~~\n"), Some(4));
391 assert_eq!(line_of_todo("## N\n\n ok\n TODO\n"), Some(4));
392 }
393
394 #[test]
395 #[should_panic(expected = "text-requires-purity")]
396 fn requires_rejects_the_absence_form() {
397 requires(vec![TextMatchSpec {
398 max: Some(0.0),
399 ..pattern("x")
400 }]);
401 }
402
403 #[test]
404 #[should_panic(expected = "contract/text-count-bound")]
405 fn forbids_rejects_a_negative_max() {
406 forbids(vec![TextMatchSpec {
407 max: Some(-1.0),
408 ..pattern("x")
409 }]);
410 }
411
412 #[test]
413 #[should_panic(expected = "invalid regex /(/")]
414 fn a_bad_regex_panics_at_construction() {
415 forbids(vec![TextMatchSpec {
416 regex: Some("(".into()),
417 ..Default::default()
418 }]);
419 }
420}