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) -> &str {
11 "MD043"
12 }
13
14 fn description(&self) -> &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(|s| s.to_string()))
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[heading_index];
59 if expected != "*" && text != expected {
61 violations.push(Violation {
62 line: current_heading_line,
63 column: Some(1),
64 rule: self.name().to_string(),
65 message: format!(
66 "Expected heading '{}', found '{}'",
67 expected, text
68 ),
69 fix: None,
70 });
71 }
72 } else {
73 violations.push(Violation {
75 line: current_heading_line,
76 column: Some(1),
77 rule: self.name().to_string(),
78 message: format!("Unexpected heading: '{}'", text),
79 fix: None,
80 });
81 }
82
83 heading_index += 1;
84 in_heading = false;
85 }
86 _ => {}
87 }
88 }
89
90 if heading_index < required_headings.len() {
92 violations.push(Violation {
93 line: parser.lines().len(),
94 column: Some(1),
95 rule: self.name().to_string(),
96 message: format!(
97 "Missing required headings (expected {}, found {})",
98 required_headings.len(),
99 heading_index
100 ),
101 fix: None,
102 });
103 }
104
105 violations
106 }
107
108 fn fixable(&self) -> bool {
109 false
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn test_no_config() {
119 let content = "# Any Heading\n## Any Subheading";
120 let parser = MarkdownParser::new(content);
121 let rule = MD043;
122 let violations = rule.check(&parser, None);
123
124 assert_eq!(violations.len(), 0); }
126
127 #[test]
128 fn test_correct_structure() {
129 let content = "# Introduction\n## Background\n## Methods";
130 let parser = MarkdownParser::new(content);
131 let rule = MD043;
132 let config = serde_json::json!({
133 "headings": ["Introduction", "Background", "Methods"]
134 });
135 let violations = rule.check(&parser, Some(&config));
136
137 assert_eq!(violations.len(), 0);
138 }
139
140 #[test]
141 fn test_wrong_heading() {
142 let content = "# Introduction\n## Wrong Heading";
143 let parser = MarkdownParser::new(content);
144 let rule = MD043;
145 let config = serde_json::json!({
146 "headings": ["Introduction", "Background"]
147 });
148 let violations = rule.check(&parser, Some(&config));
149
150 assert_eq!(violations.len(), 1);
151 assert!(violations[0].message.contains("Wrong Heading"));
152 }
153
154 #[test]
155 fn test_wildcard() {
156 let content = "# Introduction\n## Any Text Here\n## Methods";
157 let parser = MarkdownParser::new(content);
158 let rule = MD043;
159 let config = serde_json::json!({
160 "headings": ["Introduction", "*", "Methods"]
161 });
162 let violations = rule.check(&parser, Some(&config));
163
164 assert_eq!(violations.len(), 0); }
166}