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) -> &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        // 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[heading_index];
59                        // Support wildcards (*)
60                        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                        // Extra heading not in structure
74                        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        // Check if we have fewer headings than required
91        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); // No required structure, no violations
125    }
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); // Wildcard matches anything
165    }
166}