Skip to main content

markdown_contract/
text_constraints.rs

1//! Text-constraint builders: [`requires`] and [`forbids`] (section rules) and
2//! [`text_rule`] (a document rule), ported from the TS `text-constraints.ts`.
3//!
4//! Each builder runs the text-match core ([`crate::text_match`]) over its scope's text.
5//! The text is rebuilt from the projected tree with every block (prose, list items,
6//! table cells, code) on its real source line, so a `forbids` hit reports its true
7//! line. A section scope's key is the heading's lowerCamelCase key; the document's is
8//! `"doc"`.
9//!
10//! A builder panics on an authoring error, as the TS builders throw: a `requires` entry
11//! whose bound means absence (`max: 0`, or `max` below the minimum), a `forbids` entry
12//! with a negative `max`, an entry with no needle, or a `regex` the `regex` crate
13//! rejects. The declarative compiler screens the
14//! same errors out first as a `DeclarativeError`.
15
16use 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
28// ── Count bounds that can never be met ─────────────────────────────────────────────
29
30/// Reject the first entry whose count bound [`count_bound_problem`] refuses: a
31/// `requires` whose bound means absence (`max: 0`, or any `max` below the entry's
32/// minimum), or a `forbids` with a negative `max`. Panics, as the TS builders throw
33/// `ContractBuildError`.
34fn 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
42// ── Line-faithful scope-text reconstruction ───────────────────────────────────────────
43
44/// Accumulates text fragments at their 1-based source lines and renders them into one
45/// string whose newline structure mirrors the source. A multi-line fragment places each
46/// of its lines on a consecutive source line; two fragments on one line space-join.
47struct 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
82/// Place one projected block's text at its real source line(s).
83fn 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
111/// The section's subtree text, reconstructed line-faithfully: every block of this
112/// section and every descendant, placed at its real source line.
113fn 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
127/// The whole-document scope text, reconstructed line-faithfully from the projected tree.
128fn doc_scope_text(tree: &DocTree) -> String {
129    section_scope_text(&tree.root)
130}
131
132// ── The builders ──────────────────────────────────────────────────────────────────────
133
134/// A spec with its needle compiled.
135pub(crate) type CompiledSpec = (TextMatchSpec, Option<Regex>);
136
137type Compiled = Vec<CompiledSpec>;
138
139/// # Panics
140/// When a spec has no needle or its `regex` does not compile.
141fn 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
183/// Require each listed phrase to be PRESENT in the bound section's subtree text — a
184/// node-local [`Rule`] for a section's `rules` slot. Each entry emits its own
185/// `text/requires` finding at the section heading when its phrase is absent (or
186/// `text/count` when a `min` / `max` bound is violated).
187///
188/// # Panics
189/// On an absence-form entry (`max: 0` or `max < min`; use [`forbids`]), an entry with
190/// no needle, or a `regex` the `regex` crate rejects.
191pub fn requires(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
192    assert_count_bounds(TextKind::Requires, &specs);
193    requires_compiled(compile(specs))
194}
195
196/// [`requires`] over specs the declarative loader has already screened and compiled.
197pub(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
205/// Forbid each listed phrase from appearing in the bound section's subtree text — a
206/// node-local [`Rule`]. Each entry emits a `text/forbids` finding at the offending line
207/// for every hit (or `text/count` at the heading when a positive `max` cap is exceeded).
208///
209/// # Panics
210/// On an entry with a negative `max`, an entry with no needle, or a `regex` the `regex`
211/// crate rejects.
212pub fn forbids(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
213    assert_count_bounds(TextKind::Forbids, &specs);
214    forbids_compiled(compile(specs))
215}
216
217/// [`forbids`] over specs the declarative loader has already compiled.
218pub(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/// The document-scoped options for [`text_rule`] — `requires` / `forbids` lists, each a
227/// set of independent whole-document checks.
228#[derive(Default)]
229pub struct TextRuleSpec {
230    /// entries that must be found
231    pub requires: Vec<TextMatchSpec>,
232    /// entries that must not be found
233    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
279/// Attach required / forbidden phrase checks to the WHOLE document — a cross-plane
280/// [`DocRule`] for a contract's `rules` slot. A `requires` miss is document-level (no
281/// position); each `forbids` hit anchors at the exact offending source line.
282///
283/// # Panics
284/// As [`requires`] and [`forbids`] do.
285pub 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
291/// [`text_rule`] over specs the declarative loader has already screened and compiled.
292pub(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    // Contract first — the v22 fixture shape: a section-scoped requires misses and pins
318    // the section heading with the synthesized id the goldens pin.
319    #[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    // The v23 shape: a document-scoped forbids hit anchors at the offending line.
347    #[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    // Scope text is the SUBTREE: a child section's list items are in scope, at their
368    // real source lines.
369    #[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"); // line 3
377        assert_eq!(lines[6], "item one"); // line 7
378        assert_eq!(lines[9], "A B"); // line 10 (table header)
379        assert_eq!(lines[11], "x y"); // line 12 (data row; separator line stays empty)
380    }
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}