1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::RuleConfig;
8use crate::rules::heading_utils::HeadingStyle;
9use crate::utils::range_utils::calculate_heading_range;
10use lazy_static::lazy_static;
11use regex::Regex;
12use toml;
13
14mod md003_config;
15use md003_config::MD003Config;
16
17lazy_static! {
18 static ref FRONT_MATTER_DELIMITER: Regex = Regex::new(r"^---\s*$").unwrap();
19 static ref QUICK_HEADING_CHECK: Regex = Regex::new(r"(?m)^(\s*)#|^(\s*)[^\s].*\n(\s*)(=+|-+)\s*$").unwrap();
20}
21
22#[derive(Clone, Default)]
24pub struct MD003HeadingStyle {
25 config: MD003Config,
26}
27
28impl MD003HeadingStyle {
29 pub fn new(style: HeadingStyle) -> Self {
30 Self {
31 config: MD003Config { style },
32 }
33 }
34
35 pub fn from_config_struct(config: MD003Config) -> Self {
36 Self { config }
37 }
38
39 fn is_consistent_mode(&self) -> bool {
41 self.config.style == HeadingStyle::Consistent
43 }
44
45 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
47 if !self.is_consistent_mode() {
48 return self.config.style;
49 }
50
51 for line_info in &ctx.lines {
53 if let Some(heading) = &line_info.heading {
54 return match heading.style {
56 crate::lint_context::HeadingStyle::ATX => {
57 if heading.has_closing_sequence {
58 HeadingStyle::AtxClosed
59 } else {
60 HeadingStyle::Atx
61 }
62 }
63 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
64 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
65 };
66 }
67 }
68
69 HeadingStyle::Atx
71 }
72}
73
74impl Rule for MD003HeadingStyle {
75 fn name(&self) -> &'static str {
76 "MD003"
77 }
78
79 fn description(&self) -> &'static str {
80 "Heading style"
81 }
82
83 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
84 let mut result = Vec::new();
85
86 let target_style = self.get_target_style(ctx);
88
89 let line_index = crate::utils::range_utils::LineIndex::new(ctx.content.to_string());
91
92 for (line_num, line_info) in ctx.lines.iter().enumerate() {
94 if let Some(heading) = &line_info.heading {
95 let level = heading.level;
96
97 let current_style = match heading.style {
99 crate::lint_context::HeadingStyle::ATX => {
100 if heading.has_closing_sequence {
101 HeadingStyle::AtxClosed
102 } else {
103 HeadingStyle::Atx
104 }
105 }
106 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
107 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
108 };
109
110 let expected_style = match target_style {
112 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
113 if level > 2 {
114 HeadingStyle::Atx
116 } else if level == 1 {
117 HeadingStyle::Setext1
118 } else {
119 HeadingStyle::Setext2
120 }
121 }
122 HeadingStyle::SetextWithAtx => {
123 if level <= 2 {
124 if level == 1 {
126 HeadingStyle::Setext1
127 } else {
128 HeadingStyle::Setext2
129 }
130 } else {
131 HeadingStyle::Atx
133 }
134 }
135 HeadingStyle::SetextWithAtxClosed => {
136 if level <= 2 {
137 if level == 1 {
139 HeadingStyle::Setext1
140 } else {
141 HeadingStyle::Setext2
142 }
143 } else {
144 HeadingStyle::AtxClosed
146 }
147 }
148 _ => target_style,
149 };
150
151 if current_style != expected_style {
152 let fix = {
154 use crate::rules::heading_utils::HeadingUtils;
155
156 let converted_heading =
158 HeadingUtils::convert_heading_style(&heading.text, level as u32, expected_style);
159
160 let final_heading = format!("{}{}", " ".repeat(line_info.indent), converted_heading);
162
163 let range = line_index.line_content_range(line_num + 1);
165
166 Some(crate::rule::Fix {
167 range,
168 replacement: final_heading,
169 })
170 };
171
172 let (start_line, start_col, end_line, end_col) =
174 calculate_heading_range(line_num + 1, &line_info.content);
175
176 result.push(LintWarning {
177 rule_name: Some(self.name()),
178 line: start_line,
179 column: start_col,
180 end_line,
181 end_column: end_col,
182 message: format!(
183 "Heading style should be {}, found {}",
184 match expected_style {
185 HeadingStyle::Atx => "# Heading",
186 HeadingStyle::AtxClosed => "# Heading #",
187 HeadingStyle::Setext1 => "Heading\n=======",
188 HeadingStyle::Setext2 => "Heading\n-------",
189 HeadingStyle::Consistent => "consistent with the first heading",
190 HeadingStyle::SetextWithAtx => "setext_with_atx style",
191 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
192 },
193 match current_style {
194 HeadingStyle::Atx => "# Heading",
195 HeadingStyle::AtxClosed => "# Heading #",
196 HeadingStyle::Setext1 => "Heading (underlined with =)",
197 HeadingStyle::Setext2 => "Heading (underlined with -)",
198 HeadingStyle::Consistent => "consistent style",
199 HeadingStyle::SetextWithAtx => "setext_with_atx style",
200 HeadingStyle::SetextWithAtxClosed => "setext_with_atx_closed style",
201 }
202 ),
203 severity: Severity::Warning,
204 fix,
205 });
206 }
207 }
208 }
209
210 Ok(result)
211 }
212
213 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
214 let warnings = self.check(ctx)?;
216
217 if warnings.is_empty() {
219 return Ok(ctx.content.to_string());
220 }
221
222 let mut fixes: Vec<_> = warnings
224 .iter()
225 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
226 .collect();
227 fixes.sort_by(|a, b| b.0.cmp(&a.0));
228
229 let mut result = ctx.content.to_string();
231 for (start, end, replacement) in fixes {
232 if start < result.len() && end <= result.len() && start <= end {
233 result.replace_range(start..end, replacement);
234 }
235 }
236
237 Ok(result)
238 }
239
240 fn category(&self) -> RuleCategory {
241 RuleCategory::Heading
242 }
243
244 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
245 ctx.content.is_empty() || !ctx.lines.iter().any(|line| line.heading.is_some())
247 }
248
249 fn as_any(&self) -> &dyn std::any::Any {
250 self
251 }
252
253 fn default_config_section(&self) -> Option<(String, toml::Value)> {
254 let default_config = MD003Config::default();
255 let json_value = serde_json::to_value(&default_config).ok()?;
256 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
257
258 if let toml::Value::Table(table) = toml_value {
259 if !table.is_empty() {
260 Some((MD003Config::RULE_NAME.to_string(), toml::Value::Table(table)))
261 } else {
262 None
263 }
264 } else {
265 None
266 }
267 }
268
269 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
270 where
271 Self: Sized,
272 {
273 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
274 Box::new(Self::from_config_struct(rule_config))
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use crate::lint_context::LintContext;
282
283 #[test]
284 fn test_atx_heading_style() {
285 let rule = MD003HeadingStyle::default();
286 let content = "# Heading 1\n## Heading 2\n### Heading 3";
287 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
288 let result = rule.check(&ctx).unwrap();
289 assert!(result.is_empty());
290 }
291
292 #[test]
293 fn test_setext_heading_style() {
294 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
295 let content = "Heading 1\n=========\n\nHeading 2\n---------";
296 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
297 let result = rule.check(&ctx).unwrap();
298 assert!(result.is_empty());
299 }
300
301 #[test]
302 fn test_front_matter() {
303 let rule = MD003HeadingStyle::default();
304 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
305
306 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
308 let result = rule.check(&ctx).unwrap();
309 assert!(
310 result.is_empty(),
311 "No warnings expected for content with front matter, found: {result:?}"
312 );
313 }
314
315 #[test]
316 fn test_consistent_heading_style() {
317 let rule = MD003HeadingStyle::default();
319 let content = "# Heading 1\n## Heading 2\n### Heading 3";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
321 let result = rule.check(&ctx).unwrap();
322 assert!(result.is_empty());
323 }
324
325 #[test]
326 fn test_with_different_styles() {
327 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
329 let content = "# Heading 1\n## Heading 2\n### Heading 3";
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
331 let result = rule.check(&ctx).unwrap();
332
333 assert!(
335 result.is_empty(),
336 "No warnings expected for consistent ATX style, found: {result:?}"
337 );
338
339 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
341 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
343 let result = rule.check(&ctx).unwrap();
344 assert!(
345 !result.is_empty(),
346 "Should have warnings for inconsistent heading styles"
347 );
348
349 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
351 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
352 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
353 let result = rule.check(&ctx).unwrap();
354 assert!(
356 result.is_empty(),
357 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
358 );
359 }
360
361 #[test]
362 fn test_setext_with_atx_style() {
363 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
364 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
367 let result = rule.check(&ctx).unwrap();
368 assert!(
369 result.is_empty(),
370 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
371 );
372
373 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
375 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard);
376 let result_wrong = rule.check(&ctx_wrong).unwrap();
377 assert_eq!(
378 result_wrong.len(),
379 2,
380 "Should flag ATX headings for h1/h2 with setext_with_atx style"
381 );
382 }
383
384 #[test]
385 fn test_setext_with_atx_closed_style() {
386 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
387 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard);
390 let result = rule.check(&ctx).unwrap();
391 assert!(
392 result.is_empty(),
393 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
394 );
395
396 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
398 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard);
399 let result_wrong = rule.check(&ctx_wrong).unwrap();
400 assert_eq!(
401 result_wrong.len(),
402 2,
403 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
404 );
405 }
406}