use regex::Regex;
use crate::camel::to_camel_key;
use crate::contract::{DocRule, Rule};
use crate::finding::Finding;
use crate::registry::Ctx;
use crate::text_match::{
TextFindingInput, TextKind, TextMatchSpec, build_regex_or_panic, build_text_findings,
count_bound_problem, match_text,
};
use crate::tree::{BlockNode, DocTree, SectionNode};
fn assert_count_bounds(kind: TextKind, specs: &[TextMatchSpec]) {
for spec in specs {
if let Some(problem) = count_bound_problem(kind, spec) {
panic!("{}: {}", problem.id, problem.message);
}
}
}
struct LineBuffer {
lines: Vec<String>,
}
impl LineBuffer {
fn new() -> Self {
Self { lines: Vec::new() }
}
fn place(&mut self, line: u32, text: &str) {
if text.is_empty() {
return;
}
for (i, part) in text.split('\n').enumerate() {
let Some(idx) = (line as usize + i).checked_sub(1) else {
continue;
};
while self.lines.len() <= idx {
self.lines.push(String::new());
}
let existing = &mut self.lines[idx];
if existing.is_empty() {
existing.push_str(part);
} else {
existing.push(' ');
existing.push_str(part);
}
}
}
fn render(&self) -> String {
self.lines.join("\n")
}
}
fn place_block(buf: &mut LineBuffer, block: &BlockNode) {
match block {
BlockNode::Paragraph { text, pos, .. } => buf.place(pos.line, text),
BlockNode::List { items, .. } => {
for item in items {
buf.place(item.pos.line, &item.text);
}
}
BlockNode::Code {
value, fenced, pos, ..
} => buf.place(pos.line + u32::from(*fenced), value),
BlockNode::Table {
columns,
rows,
row_pos,
pos,
..
} => {
buf.place(pos.line, &columns.join(" "));
for (i, row) in rows.iter().enumerate() {
if let Some(rp) = row_pos.get(i) {
buf.place(rp.line, &row.join(" "));
}
}
}
}
}
fn section_scope_text(node: &SectionNode) -> String {
fn walk(n: &SectionNode, buf: &mut LineBuffer) {
for block in &n.blocks {
place_block(buf, block);
}
for child in &n.sections {
walk(child, buf);
}
}
let mut buf = LineBuffer::new();
walk(node, &mut buf);
buf.render()
}
fn doc_scope_text(tree: &DocTree) -> String {
section_scope_text(&tree.root)
}
pub(crate) type CompiledSpec = (TextMatchSpec, Option<Regex>);
type Compiled = Vec<CompiledSpec>;
fn compile(specs: Vec<TextMatchSpec>) -> Compiled {
specs
.into_iter()
.map(|spec| {
let re = build_regex_or_panic(&spec);
(spec, re)
})
.collect()
}
struct TextNodeRule {
kind: TextKind,
id: &'static str,
specs: Compiled,
}
impl Rule for TextNodeRule {
fn id(&self) -> &str {
self.id
}
fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding> {
let scope_key = to_camel_key(&node.name);
let text = section_scope_text(node);
let mut out = Vec::new();
for (spec, re) in &self.specs {
out.extend(build_text_findings(
TextFindingInput {
kind: self.kind,
spec,
result: match_text(&text, re.as_ref()),
scope_key: &scope_key,
scope: Some(&node.name),
scope_pos: Some(node.pos),
},
ctx,
));
}
out
}
}
pub fn requires(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
assert_count_bounds(TextKind::Requires, &specs);
requires_compiled(compile(specs))
}
pub(crate) fn requires_compiled(specs: Compiled) -> Box<dyn Rule> {
Box::new(TextNodeRule {
kind: TextKind::Requires,
id: "text/requires",
specs,
})
}
pub fn forbids(specs: Vec<TextMatchSpec>) -> Box<dyn Rule> {
assert_count_bounds(TextKind::Forbids, &specs);
forbids_compiled(compile(specs))
}
pub(crate) fn forbids_compiled(specs: Compiled) -> Box<dyn Rule> {
Box::new(TextNodeRule {
kind: TextKind::Forbids,
id: "text/forbids",
specs,
})
}
#[derive(Default)]
pub struct TextRuleSpec {
pub requires: Vec<TextMatchSpec>,
pub forbids: Vec<TextMatchSpec>,
}
struct TextDocRule {
requires: Compiled,
forbids: Compiled,
}
impl DocRule for TextDocRule {
fn id(&self) -> &str {
"text/doc"
}
fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding> {
let text = doc_scope_text(tree);
let mut out = Vec::new();
for (spec, re) in &self.requires {
out.extend(build_text_findings(
TextFindingInput {
kind: TextKind::Requires,
spec,
result: match_text(&text, re.as_ref()),
scope_key: "doc",
scope: None,
scope_pos: None,
},
ctx,
));
}
for (spec, re) in &self.forbids {
out.extend(build_text_findings(
TextFindingInput {
kind: TextKind::Forbids,
spec,
result: match_text(&text, re.as_ref()),
scope_key: "doc",
scope: None,
scope_pos: None,
},
ctx,
));
}
out
}
}
pub fn text_rule(spec: TextRuleSpec) -> Box<dyn DocRule> {
assert_count_bounds(TextKind::Requires, &spec.requires);
assert_count_bounds(TextKind::Forbids, &spec.forbids);
text_rule_compiled(compile(spec.requires), compile(spec.forbids))
}
pub(crate) fn text_rule_compiled(requires: Compiled, forbids: Compiled) -> Box<dyn DocRule> {
Box::new(TextDocRule { requires, forbids })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{Contract, LevelOpts, SectionOpts, section, section_with, sections};
use crate::finding::FindingLevel;
use crate::validate::validate;
fn pattern(p: &str) -> TextMatchSpec {
TextMatchSpec {
pattern: Some(p.into()),
..Default::default()
}
}
fn brief(findings: &[Finding]) -> Vec<(&str, FindingLevel, Option<u32>)> {
findings
.iter()
.map(|f| (f.id.as_str(), f.level, f.pos.map(|p| p.line)))
.collect()
}
#[test]
fn section_requires_miss_pins_the_heading() {
let build = || {
Contract::new().body(sections(
LevelOpts::default(),
vec![section_with(
"Summary",
SectionOpts {
rules: vec![requires(vec![pattern("outcome")])],
..Default::default()
},
)],
))
};
let pass = "## Summary\n\nThe outcome is recorded here.\n";
assert_eq!(validate(pass, &build(), "d.md"), vec![]);
let fail = "## Summary\n\nThis decision records the chosen direction.\n";
assert_eq!(
brief(&validate(fail, &build(), "d.md")),
vec![(
"text/requires/summary/1tc7itx",
FindingLevel::Error,
Some(1)
)]
);
}
#[test]
fn doc_forbids_hit_anchors_the_offending_line() {
let build = || {
Contract::new()
.body(sections(LevelOpts::default(), vec![section("Summary")]))
.doc_rule(text_rule(TextRuleSpec {
forbids: vec![TextMatchSpec {
normalize: Some(false),
..pattern("}scripts/")
}],
..Default::default()
}))
};
let fail = "## Summary\n\nCallers reach into }scripts/legacy.sh directly.\n";
assert_eq!(
brief(&validate(fail, &build(), "d.md")),
vec![("text/forbids/doc/o9pijh", FindingLevel::Error, Some(3))]
);
}
#[test]
fn scope_text_reconstruction_is_line_faithful() {
let tree = crate::parse::parse_document(
"## Top\n\nintro prose\n\n### Child\n\n- item one\n- item two\n\n| A | B |\n| - | - |\n| x | y |\n",
);
let text = section_scope_text(&tree.root.sections[0]);
let lines: Vec<&str> = text.split('\n').collect();
assert_eq!(lines[2], "intro prose"); assert_eq!(lines[6], "item one"); assert_eq!(lines[9], "A B"); assert_eq!(lines[11], "x y"); }
#[test]
fn code_text_starts_after_the_fence_and_on_the_line_of_indented_code() {
let line_of_todo = |src: &str| {
let tree = crate::parse::parse_document(src);
let text = section_scope_text(&tree.root.sections[0]);
text.split('\n').position(|l| l == "TODO").map(|i| i + 1)
};
assert_eq!(line_of_todo("## N\n\n```sh\nok\nTODO\n```\n"), Some(5));
assert_eq!(line_of_todo("## N\n\n~~~\nTODO\n~~~\n"), Some(4));
assert_eq!(line_of_todo("## N\n\n ok\n TODO\n"), Some(4));
}
#[test]
#[should_panic(expected = "text-requires-purity")]
fn requires_rejects_the_absence_form() {
requires(vec![TextMatchSpec {
max: Some(0.0),
..pattern("x")
}]);
}
#[test]
#[should_panic(expected = "contract/text-count-bound")]
fn forbids_rejects_a_negative_max() {
forbids(vec![TextMatchSpec {
max: Some(-1.0),
..pattern("x")
}]);
}
#[test]
#[should_panic(expected = "invalid regex /(/")]
fn a_bad_regex_panics_at_construction() {
forbids(vec![TextMatchSpec {
regex: Some("(".into()),
..Default::default()
}]);
}
}