Skip to main content

mdlint/lint/rules/
md043.rs

1use 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        // If no required structure is specified, skip check
33        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                        // Support wildcards (*)
62                        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                        // Extra heading not in structure
73                        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        // Check if we have fewer headings than required
90        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); // No required structure, no violations
124    }
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); // Wildcard matches anything
164    }
165}