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) -> &str {
17        "MD003"
18    }
19
20    fn description(&self) -> &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().unwrap().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.lines()[line_num + 1];
59                let is_setext_underline =
60                    (next_line.chars().all(|c| c == '=' || c.is_whitespace())
61                        && next_line.contains('='))
62                        || (next_line.chars().all(|c| c == '-' || c.is_whitespace())
63                            && next_line.contains('-')
64                            && next_line.trim().len() >= 3);
65
66                if is_setext_underline {
67                    Some(HeadingStyle::Setext)
68                } else {
69                    None
70                }
71            } else {
72                None
73            };
74
75            if let Some(current) = current_style {
76                if style == "consistent" {
77                    if let Some(first) = first_style {
78                        if current != first {
79                            violations.push(Violation {
80                                line: line_number,
81                                column: Some(1),
82                                rule: self.name().to_string(),
83                                message: format!(
84                                    "Heading style should be consistent (expected {:?}, found {:?})",
85                                    first, current
86                                ),
87                                fix: None,
88                            });
89                        }
90                    } else {
91                        first_style = Some(current);
92                    }
93                } else {
94                    let required_style = match style {
95                        "atx" => HeadingStyle::Atx,
96                        "atx_closed" => HeadingStyle::AtxClosed,
97                        "setext" => HeadingStyle::Setext,
98                        _ => continue,
99                    };
100
101                    if current != required_style {
102                        violations.push(Violation {
103                            line: line_number,
104                            column: Some(1),
105                            rule: self.name().to_string(),
106                            message: format!(
107                                "Heading style should be {:?} but found {:?}",
108                                required_style, current
109                            ),
110                            fix: None,
111                        });
112                    }
113                }
114            }
115        }
116
117        violations
118    }
119
120    fn fixable(&self) -> bool {
121        false
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_consistent_atx() {
131        let content = "# Heading 1\n## Heading 2\n### Heading 3";
132        let parser = MarkdownParser::new(content);
133        let rule = MD003;
134        let violations = rule.check(&parser, None);
135
136        assert_eq!(violations.len(), 0);
137    }
138
139    #[test]
140    fn test_inconsistent_styles() {
141        let content = "# Heading 1\n## Heading 2 ##\n### Heading 3";
142        let parser = MarkdownParser::new(content);
143        let rule = MD003;
144        let violations = rule.check(&parser, None);
145
146        assert!(!violations.is_empty());
147    }
148
149    #[test]
150    fn test_enforced_atx_style() {
151        let content = "# Heading 1\n## Heading 2 ##";
152        let parser = MarkdownParser::new(content);
153        let rule = MD003;
154        let config = serde_json::json!({ "style": "atx" });
155        let violations = rule.check(&parser, Some(&config));
156
157        assert_eq!(violations.len(), 1); // Second heading has closing #
158    }
159
160    #[test]
161    fn test_setext_detection() {
162        let content = "Heading 1\n=========\n\nHeading 2\n---------";
163        let parser = MarkdownParser::new(content);
164        let rule = MD003;
165        let config = serde_json::json!({ "style": "consistent" });
166        let violations = rule.check(&parser, Some(&config));
167
168        assert_eq!(violations.len(), 0); // Both setext style
169    }
170
171    #[test]
172    fn test_horizontal_rules_not_flagged() {
173        // Horizontal rules (---) after blank lines should not be detected as setext headings
174        let content = "# Heading 1\n\n---\n\nContent here.\n\n***\n\nMore content.";
175        let parser = MarkdownParser::new(content);
176        let rule = MD003;
177        let violations = rule.check(&parser, None);
178
179        assert_eq!(violations.len(), 0); // HR is not a heading
180    }
181
182    #[test]
183    fn test_setext_in_code_block_not_flagged() {
184        let content = "# Real heading\n\n```markdown\nSetext heading\n==============\n```\n";
185        let parser = MarkdownParser::new(content);
186        let rule = MD003;
187        let config = serde_json::json!({ "style": "atx" });
188        let violations = rule.check(&parser, Some(&config));
189
190        assert_eq!(violations.len(), 0);
191    }
192}