mdlint/lint/rules/
md043.rs1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use pulldown_cmark::{Event, Tag, TagEnd};
5use serde_json::Value;
6
7pub struct MD043;
8
9impl Rule for MD043 {
10 fn name(&self) -> &'static str {
11 "MD043"
12 }
13
14 fn description(&self) -> &'static str {
15 "Required heading structure"
16 }
17
18 fn tags(&self) -> &[&str] {
19 &["headings"]
20 }
21
22 fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
23 let headings = config
24 .and_then(|c| c.get("headings"))
25 .and_then(|v| v.as_array())
26 .map(|arr| {
27 arr.iter()
28 .filter_map(|v| v.as_str().map(str::to_owned))
29 .collect::<Vec<_>>()
30 });
31
32 let required_headings = match headings {
34 Some(h) if !h.is_empty() => h,
35 _ => return Vec::new(),
36 };
37
38 let mut violations = Vec::new();
39 let mut heading_index = 0;
40 let mut in_heading = false;
41 let mut current_heading_text = String::new();
42 let mut current_heading_line = 0;
43
44 for (event, range) in parser.parse_with_offsets() {
45 match event {
46 Event::Start(Tag::Heading { .. }) => {
47 in_heading = true;
48 current_heading_text.clear();
49 current_heading_line = parser.offset_to_line(range.start);
50 }
51 Event::Text(text) if in_heading => {
52 current_heading_text.push_str(&text);
53 }
54 Event::End(TagEnd::Heading(_)) if in_heading => {
55 let text = current_heading_text.trim();
56
57 if heading_index < required_headings.len() {
58 let expected = required_headings
59 .get(heading_index)
60 .expect("heading_index < required_headings.len()");
61 if expected != "*" && text != expected {
63 violations.push(Violation {
64 line: current_heading_line,
65 column: Some(1),
66 rule: self.name().to_owned(),
67 message: format!("Expected heading '{expected}', found '{text}'"),
68 fix: None,
69 });
70 }
71 } else {
72 violations.push(Violation {
74 line: current_heading_line,
75 column: Some(1),
76 rule: self.name().to_owned(),
77 message: format!("Unexpected heading: '{text}'"),
78 fix: None,
79 });
80 }
81
82 heading_index += 1;
83 in_heading = false;
84 }
85 _ => {}
86 }
87 }
88
89 if heading_index < required_headings.len() {
91 violations.push(Violation {
92 line: parser.lines().len(),
93 column: Some(1),
94 rule: self.name().to_owned(),
95 message: format!(
96 "Missing required headings (expected {}, found {})",
97 required_headings.len(),
98 heading_index
99 ),
100 fix: None,
101 });
102 }
103
104 violations
105 }
106
107 fn fixable(&self) -> bool {
108 false
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 #[test]
117 fn test_no_config() {
118 let content = "# Any Heading\n## Any Subheading";
119 let parser = MarkdownParser::new(content);
120 let rule = MD043;
121 let violations = rule.check(&parser, None);
122
123 assert_eq!(violations.len(), 0); }
125
126 #[test]
127 fn test_correct_structure() {
128 let content = "# Introduction\n## Background\n## Methods";
129 let parser = MarkdownParser::new(content);
130 let rule = MD043;
131 let config = serde_json::json!({
132 "headings": ["Introduction", "Background", "Methods"]
133 });
134 let violations = rule.check(&parser, Some(&config));
135
136 assert_eq!(violations.len(), 0);
137 }
138
139 #[test]
140 fn test_wrong_heading() {
141 let content = "# Introduction\n## Wrong Heading";
142 let parser = MarkdownParser::new(content);
143 let rule = MD043;
144 let config = serde_json::json!({
145 "headings": ["Introduction", "Background"]
146 });
147 let violations = rule.check(&parser, Some(&config));
148
149 assert_eq!(violations.len(), 1);
150 assert!(violations[0].message.contains("Wrong Heading"));
151 }
152
153 #[test]
154 fn test_wildcard() {
155 let content = "# Introduction\n## Any Text Here\n## Methods";
156 let parser = MarkdownParser::new(content);
157 let rule = MD043;
158 let config = serde_json::json!({
159 "headings": ["Introduction", "*", "Methods"]
160 });
161 let violations = rule.check(&parser, Some(&config));
162
163 assert_eq!(violations.len(), 0); }
165}