mdlint/lint/rules/
md003.rs1use 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, AtxClosed, Setext, }
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 let current_style = if trimmed.starts_with('#') {
47 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 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); }
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); }
172
173 #[test]
174 fn test_horizontal_rules_not_flagged() {
175 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); }
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}