use crate::content::match_content;
use crate::contract::Contract;
use crate::finding::Finding;
use crate::parse::parse_document;
use crate::registry::{Ctx, Registry, takes_hint};
use crate::structure::{match_structure, scan_heading_depth_jumps};
use crate::tree::DocTree;
const PLANE_ORDER: [&str; 5] = ["frontmatter", "structure", "content", "text", "rule"];
fn plane_rank(id: &str) -> usize {
let area = id.split('/').next().unwrap_or("");
PLANE_ORDER
.iter()
.position(|p| *p == area)
.unwrap_or(PLANE_ORDER.len() - 1)
}
pub fn sort_findings(mut findings: Vec<Finding>) -> Vec<Finding> {
findings.sort_by_key(|f| {
let line = f.pos.map_or(0, |p| p.line);
let col = f.pos.and_then(|p| p.col).unwrap_or(0);
(line, col, plane_rank(&f.id))
});
findings
}
pub fn validate_tree(tree: &DocTree, contract: &Contract, path: &str) -> Vec<Finding> {
let registry = Registry::default();
let ctx = Ctx::new(path, ®istry);
let mut findings = scan_heading_depth_jumps(&tree.root, &ctx);
if let Some(body) = &contract.body {
findings.extend(match_structure(&tree.root, body, &ctx));
}
findings.extend(match_content(tree, contract, &ctx));
for r in &contract.rules {
findings.extend(r.run(tree, &ctx));
}
if let Some(root_hint) = &contract.description {
for finding in &mut findings {
if finding.hint.is_none() && takes_hint(&finding.id) {
finding.hint = Some(root_hint.clone());
}
}
}
sort_findings(findings)
}
pub fn validate(source: &str, contract: &Contract, path: &str) -> Vec<Finding> {
validate_tree(&parse_document(source), contract, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{Contract, LevelOpts, doc_rule, section, sections};
use crate::finding::{FindingLevel, SourcePos};
use crate::registry::FindingSpec;
fn f(id: &str, line: Option<u32>, col: Option<u32>) -> Finding {
Finding {
id: id.into(),
level: FindingLevel::Error,
path: "doc.md".into(),
pos: line.map(|l| SourcePos { line: l, col }),
message: String::new(),
hint: None,
fix: None,
}
}
fn ids_in_order(findings: &[Finding]) -> Vec<&str> {
findings.iter().map(|x| x.id.as_str()).collect()
}
#[test]
fn sorts_by_line_with_no_pos_first() {
let out = sort_findings(vec![
f("structure/a", Some(9), None),
f("structure/b", None, None),
f("structure/c", Some(1), None),
]);
assert_eq!(
ids_in_order(&out),
vec!["structure/b", "structure/c", "structure/a"]
);
}
#[test]
fn line_ties_break_by_col_with_no_col_first() {
let out = sort_findings(vec![
f("structure/a", Some(3), Some(7)),
f("structure/b", Some(3), None),
f("structure/c", Some(3), Some(2)),
]);
assert_eq!(
ids_in_order(&out),
vec!["structure/b", "structure/c", "structure/a"]
);
}
#[test]
fn full_ties_break_by_plane_order() {
let out = sort_findings(vec![
f("task/custom", Some(3), Some(1)), f("text/requires", Some(3), Some(1)),
f("content/max-words", Some(3), Some(1)),
f("structure/block-kind", Some(3), Some(1)),
f("frontmatter/enum", Some(3), Some(1)),
]);
assert_eq!(
ids_in_order(&out),
vec![
"frontmatter/enum",
"structure/block-kind",
"content/max-words",
"text/requires",
"task/custom"
]
);
}
#[test]
fn emission_order_is_the_final_stable_tie_break() {
let out = sort_findings(vec![
f("structure/first", Some(3), Some(1)),
f("structure/second", Some(3), Some(1)),
]);
assert_eq!(
ids_in_order(&out),
vec!["structure/first", "structure/second"]
);
}
#[test]
fn validate_runs_structure_and_doc_rules_in_one_pass() {
let contract = Contract::new()
.body(sections(LevelOpts::default(), vec![section("Overview")]))
.doc_rule(doc_rule("task/post-mortem-when-worked", |tree, ctx| {
vec![
ctx.finding(
FindingSpec::new("task/post-mortem-when-worked", "post-mortem required")
.pos(tree.root.sections[0].pos),
),
]
}));
let out = validate("## Summary\n\nprose\n", &contract, "task.md");
assert_eq!(
out.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(),
vec!["structure/section-missing", "task/post-mortem-when-worked"]
);
assert_eq!(out[0].path, "task.md");
}
#[test]
fn a_contract_with_no_body_only_scans_the_outline() {
let out = validate("## A\n\n#### Deep\n\nx\n", &Contract::new(), "n.md");
assert_eq!(
out.iter()
.map(|x| (x.id.as_str(), x.level))
.collect::<Vec<_>>(),
vec![("structure/heading-depth-jump", FindingLevel::Warn)]
);
}
#[test]
fn a_yaml_error_takes_no_hint_from_the_contract_description() {
let contract = crate::declarative::load_contract(
"mcVersion: 2\nkind: contract\ndescription: d\nbody:\n sections:\n - section: A\n",
)
.unwrap();
let out = validate("---\ntitle: 'x\n---\n", &contract, "n.md");
let hint = |id: &str| out.iter().find(|f| f.id == id).map(|f| f.hint.clone());
assert_eq!(hint("frontmatter/yaml"), Some(None));
assert_eq!(
hint("structure/section-missing"),
Some(Some("d".to_string()))
);
}
}