Skip to main content

mdlint/lint/rules/
md003.rs

1use crate::lint::rule::Rule;
2use crate::markdown::MarkdownParser;
3use crate::types::Violation;
4use serde_json::Value;
5
6pub struct MD003;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9enum HeadingStyle {
10    Atx,       // # Heading
11    AtxClosed, // # Heading #
12    Setext,    // Heading\n======
13}
14
15impl Rule for MD003 {
16    fn name(&self) -> &'static str {
17        "MD003"
18    }
19
20    fn description(&self) -> &'static str {
21        "Heading style should be consistent throughout the document"
22    }
23
24    fn tags(&self) -> &[&str] {
25        &["headings", "headers"]
26    }
27
28    fn check(&self, parser: &MarkdownParser, config: Option<&Value>) -> Vec<Violation> {
29        let style = config
30            .and_then(|c| c.get("style"))
31            .and_then(|v| v.as_str())
32            .unwrap_or("atx");
33
34        let mut violations = Vec::new();
35        let mut first_style: Option<HeadingStyle> = None;
36        let code_block_lines = parser.get_code_block_line_numbers();
37
38        for (line_num, line) in parser.lines().iter().enumerate() {
39            let line_number = line_num + 1;
40            if code_block_lines.contains(&line_number) {
41                continue;
42            }
43            let trimmed = line.trim();
44
45            // Detect heading style
46            let current_style = if trimmed.starts_with('#') {
47                // ATX or ATX_CLOSED
48                // ATX_CLOSED: should have text, then space(s), then hash(es)
49                // e.g., "## Heading 2 ##"
50                let parts: Vec<&str> = trimmed.split_whitespace().collect();
51                if parts.len() >= 3 && parts.last().is_some_and(|p| p.chars().all(|c| c == '#')) {
52                    Some(HeadingStyle::AtxClosed)
53                } else {
54                    Some(HeadingStyle::Atx)
55                }
56            } else if !trimmed.is_empty() && line_num + 1 < parser.lines().len() {
57                // Check for setext (current line is heading text, next line is === or ---)
58                let next_line = parser
59                    .lines()
60                    .get(line_num + 1)
61                    .copied()
62                    .expect("bounds checked above");
63                let is_setext_underline =
64                    (next_line.chars().all(|c| c == '=' || c.is_whitespace())
65                        && next_line.contains('='))
66                        || (next_line.chars().all(|c| c == '-' || c.is_whitespace())
67                            && next_line.contains('-')
68                            && next_line.trim().len() >= 3);
69
70                if is_setext_underline {
71                    Some(HeadingStyle::Setext)
72                } else {
73                    None
74                }
75            } else {
76                None
77            };
78
79            if let Some(current) = current_style {
80                if style == "consistent" {
81                    if let Some(first) = first_style {
82                        if current != first {
83                            violations.push(Violation {
84                                line: line_number,
85                                column: Some(1),
86                                rule: self.name().to_owned(),
87                                message: format!(
88                                    "Heading style should be consistent (expected {first:?}, found {current:?})"
89                                ),
90                                fix: None,
91                            });
92                        }
93                    } else {
94                        first_style = Some(current);
95                    }
96                } else {
97                    let required_style = match style {
98                        "atx" => HeadingStyle::Atx,
99                        "atx_closed" => HeadingStyle::AtxClosed,
100                        "setext" => HeadingStyle::Setext,
101                        _ => continue,
102                    };
103
104                    if current != required_style {
105                        violations.push(Violation {
106                            line: line_number,
107                            column: Some(1),
108                            rule: self.name().to_owned(),
109                            message: format!(
110                                "Heading style should be {required_style:?} but found {current:?}"
111                            ),
112                            fix: None,
113                        });
114                    }
115                }
116            }
117        }
118
119        violations
120    }
121
122    fn fixable(&self) -> bool {
123        false
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_consistent_atx() {
133        let content = "# Heading 1\n## Heading 2\n### Heading 3";
134        let parser = MarkdownParser::new(content);
135        let rule = MD003;
136        let violations = rule.check(&parser, None);
137
138        assert_eq!(violations.len(), 0);
139    }
140
141    #[test]
142    fn test_inconsistent_styles() {
143        let content = "# Heading 1\n## Heading 2 ##\n### Heading 3";
144        let parser = MarkdownParser::new(content);
145        let rule = MD003;
146        let violations = rule.check(&parser, None);
147
148        assert!(!violations.is_empty());
149    }
150
151    #[test]
152    fn test_enforced_atx_style() {
153        let content = "# Heading 1\n## Heading 2 ##";
154        let parser = MarkdownParser::new(content);
155        let rule = MD003;
156        let config = serde_json::json!({ "style": "atx" });
157        let violations = rule.check(&parser, Some(&config));
158
159        assert_eq!(violations.len(), 1); // Second heading has closing #
160    }
161
162    #[test]
163    fn test_setext_detection() {
164        let content = "Heading 1\n=========\n\nHeading 2\n---------";
165        let parser = MarkdownParser::new(content);
166        let rule = MD003;
167        let config = serde_json::json!({ "style": "consistent" });
168        let violations = rule.check(&parser, Some(&config));
169
170        assert_eq!(violations.len(), 0); // Both setext style
171    }
172
173    #[test]
174    fn test_horizontal_rules_not_flagged() {
175        // Horizontal rules (---) after blank lines should not be detected as setext headings
176        let content = "# Heading 1\n\n---\n\nContent here.\n\n***\n\nMore content.";
177        let parser = MarkdownParser::new(content);
178        let rule = MD003;
179        let violations = rule.check(&parser, None);
180
181        assert_eq!(violations.len(), 0); // HR is not a heading
182    }
183
184    #[test]
185    fn test_setext_in_code_block_not_flagged() {
186        let content = "# Real heading\n\n```markdown\nSetext heading\n==============\n```\n";
187        let parser = MarkdownParser::new(content);
188        let rule = MD003;
189        let config = serde_json::json!({ "style": "atx" });
190        let violations = rule.check(&parser, Some(&config));
191
192        assert_eq!(violations.len(), 0);
193    }
194}