1use crate::content::match_content;
21use crate::contract::Contract;
22use crate::finding::Finding;
23use crate::parse::parse_document;
24use crate::registry::{Ctx, Registry, takes_hint};
25use crate::structure::{match_structure, scan_heading_depth_jumps};
26use crate::tree::DocTree;
27
28const PLANE_ORDER: [&str; 5] = ["frontmatter", "structure", "content", "text", "rule"];
33
34fn plane_rank(id: &str) -> usize {
36 let area = id.split('/').next().unwrap_or("");
37 PLANE_ORDER
38 .iter()
39 .position(|p| *p == area)
40 .unwrap_or(PLANE_ORDER.len() - 1)
41}
42
43pub fn sort_findings(mut findings: Vec<Finding>) -> Vec<Finding> {
47 findings.sort_by_key(|f| {
48 let line = f.pos.map_or(0, |p| p.line);
49 let col = f.pos.and_then(|p| p.col).unwrap_or(0);
50 (line, col, plane_rank(&f.id))
51 });
52 findings
53}
54
55pub fn validate_tree(tree: &DocTree, contract: &Contract, path: &str) -> Vec<Finding> {
61 let registry = Registry::default();
62 let ctx = Ctx::new(path, ®istry);
63
64 let mut findings = scan_heading_depth_jumps(&tree.root, &ctx);
67
68 if let Some(body) = &contract.body {
70 findings.extend(match_structure(&tree.root, body, &ctx));
71 }
72
73 findings.extend(match_content(tree, contract, &ctx));
76
77 for r in &contract.rules {
79 findings.extend(r.run(tree, &ctx));
80 }
81
82 if let Some(root_hint) = &contract.description {
85 for finding in &mut findings {
86 if finding.hint.is_none() && takes_hint(&finding.id) {
87 finding.hint = Some(root_hint.clone());
88 }
89 }
90 }
91
92 sort_findings(findings)
93}
94
95pub fn validate(source: &str, contract: &Contract, path: &str) -> Vec<Finding> {
98 validate_tree(&parse_document(source), contract, path)
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use crate::contract::{Contract, LevelOpts, doc_rule, section, sections};
105 use crate::finding::{FindingLevel, SourcePos};
106 use crate::registry::FindingSpec;
107
108 fn f(id: &str, line: Option<u32>, col: Option<u32>) -> Finding {
109 Finding {
110 id: id.into(),
111 level: FindingLevel::Error,
112 path: "doc.md".into(),
113 pos: line.map(|l| SourcePos { line: l, col }),
114 message: String::new(),
115 hint: None,
116 fix: None,
117 }
118 }
119
120 fn ids_in_order(findings: &[Finding]) -> Vec<&str> {
121 findings.iter().map(|x| x.id.as_str()).collect()
122 }
123
124 #[test]
126 fn sorts_by_line_with_no_pos_first() {
127 let out = sort_findings(vec![
128 f("structure/a", Some(9), None),
129 f("structure/b", None, None),
130 f("structure/c", Some(1), None),
131 ]);
132 assert_eq!(
133 ids_in_order(&out),
134 vec!["structure/b", "structure/c", "structure/a"]
135 );
136 }
137
138 #[test]
139 fn line_ties_break_by_col_with_no_col_first() {
140 let out = sort_findings(vec![
141 f("structure/a", Some(3), Some(7)),
142 f("structure/b", Some(3), None),
143 f("structure/c", Some(3), Some(2)),
144 ]);
145 assert_eq!(
146 ids_in_order(&out),
147 vec!["structure/b", "structure/c", "structure/a"]
148 );
149 }
150
151 #[test]
152 fn full_ties_break_by_plane_order() {
153 let out = sort_findings(vec![
154 f("task/custom", Some(3), Some(1)), f("text/requires", Some(3), Some(1)),
156 f("content/max-words", Some(3), Some(1)),
157 f("structure/block-kind", Some(3), Some(1)),
158 f("frontmatter/enum", Some(3), Some(1)),
159 ]);
160 assert_eq!(
161 ids_in_order(&out),
162 vec![
163 "frontmatter/enum",
164 "structure/block-kind",
165 "content/max-words",
166 "text/requires",
167 "task/custom"
168 ]
169 );
170 }
171
172 #[test]
173 fn emission_order_is_the_final_stable_tie_break() {
174 let out = sort_findings(vec![
175 f("structure/first", Some(3), Some(1)),
176 f("structure/second", Some(3), Some(1)),
177 ]);
178 assert_eq!(
179 ids_in_order(&out),
180 vec!["structure/first", "structure/second"]
181 );
182 }
183
184 #[test]
186 fn validate_runs_structure_and_doc_rules_in_one_pass() {
187 let contract = Contract::new()
188 .body(sections(LevelOpts::default(), vec![section("Overview")]))
189 .doc_rule(doc_rule("task/post-mortem-when-worked", |tree, ctx| {
190 vec![
191 ctx.finding(
192 FindingSpec::new("task/post-mortem-when-worked", "post-mortem required")
193 .pos(tree.root.sections[0].pos),
194 ),
195 ]
196 }));
197 let out = validate("## Summary\n\nprose\n", &contract, "task.md");
198 assert_eq!(
200 out.iter().map(|x| x.id.as_str()).collect::<Vec<_>>(),
201 vec!["structure/section-missing", "task/post-mortem-when-worked"]
202 );
203 assert_eq!(out[0].path, "task.md");
204 }
205
206 #[test]
207 fn a_contract_with_no_body_only_scans_the_outline() {
208 let out = validate("## A\n\n#### Deep\n\nx\n", &Contract::new(), "n.md");
209 assert_eq!(
210 out.iter()
211 .map(|x| (x.id.as_str(), x.level))
212 .collect::<Vec<_>>(),
213 vec![("structure/heading-depth-jump", FindingLevel::Warn)]
214 );
215 }
216
217 #[test]
220 fn a_yaml_error_takes_no_hint_from_the_contract_description() {
221 let contract = crate::declarative::load_contract(
222 "mcVersion: 2\nkind: contract\ndescription: d\nbody:\n sections:\n - section: A\n",
223 )
224 .unwrap();
225 let out = validate("---\ntitle: 'x\n---\n", &contract, "n.md");
226 let hint = |id: &str| out.iter().find(|f| f.id == id).map(|f| f.hint.clone());
227 assert_eq!(hint("frontmatter/yaml"), Some(None));
228 assert_eq!(
229 hint("structure/section-missing"),
230 Some(Some("d".to_string()))
231 );
232 }
233}