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) -> &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 let current_style = if trimmed.starts_with('#') {
47 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 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); }
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); }
170
171 #[test]
172 fn test_horizontal_rules_not_flagged() {
173 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); }
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}