1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rules::heading_utils::HeadingStyle;
8use crate::utils::range_utils::calculate_heading_range;
9use toml;
10
11mod md003_config;
12use md003_config::MD003Config;
13
14#[derive(Clone, Default)]
16pub struct MD003HeadingStyle {
17 config: MD003Config,
18}
19
20impl MD003HeadingStyle {
21 pub fn new(style: HeadingStyle) -> Self {
22 Self {
23 config: MD003Config { style },
24 }
25 }
26
27 pub fn from_config_struct(config: MD003Config) -> Self {
28 Self { config }
29 }
30
31 fn is_consistent_mode(&self) -> bool {
33 self.config.style == HeadingStyle::Consistent
35 }
36
37 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
39 if !self.is_consistent_mode() {
40 return self.config.style;
41 }
42
43 let mut style_counts = std::collections::HashMap::new();
45
46 for line_info in &ctx.lines {
47 if let Some(heading) = &line_info.heading {
48 if !heading.is_valid {
50 continue;
51 }
52
53 let style = match heading.style {
55 crate::lint_context::HeadingStyle::ATX => {
56 if heading.has_closing_sequence {
57 HeadingStyle::AtxClosed
58 } else {
59 HeadingStyle::Atx
60 }
61 }
62 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
63 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
64 };
65 *style_counts.entry(style).or_insert(0) += 1;
66 }
67 }
68
69 style_counts
72 .into_iter()
73 .max_by(|(style_a, count_a), (style_b, count_b)| {
74 match count_a.cmp(count_b) {
75 std::cmp::Ordering::Equal => {
76 let priority = |s: &HeadingStyle| match s {
78 HeadingStyle::Atx => 0,
79 HeadingStyle::Setext1 => 1,
80 HeadingStyle::Setext2 => 2,
81 HeadingStyle::AtxClosed => 3,
82 _ => 4,
83 };
84 priority(style_b).cmp(&priority(style_a)) }
86 other => other,
87 }
88 })
89 .map_or(HeadingStyle::Atx, |(style, _)| style)
90 }
91}
92
93impl Rule for MD003HeadingStyle {
94 fn name(&self) -> &'static str {
95 "MD003"
96 }
97
98 fn description(&self) -> &'static str {
99 "Heading style"
100 }
101
102 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
103 let mut result = Vec::new();
104
105 let target_style = self.get_target_style(ctx);
107
108 for (line_num, line_info) in ctx.lines.iter().enumerate() {
110 if let Some(heading) = &line_info.heading {
111 if !heading.is_valid {
113 continue;
114 }
115
116 let level = heading.level;
117
118 let current_style = match heading.style {
120 crate::lint_context::HeadingStyle::ATX => {
121 if heading.has_closing_sequence {
122 HeadingStyle::AtxClosed
123 } else {
124 HeadingStyle::Atx
125 }
126 }
127 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
128 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
129 };
130
131 let expected_style = match target_style {
133 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
134 if level > 2 {
135 current_style
143 } else if level == 1 {
144 HeadingStyle::Setext1
145 } else {
146 HeadingStyle::Setext2
147 }
148 }
149 HeadingStyle::SetextWithAtx => {
150 if level <= 2 {
151 if level == 1 {
153 HeadingStyle::Setext1
154 } else {
155 HeadingStyle::Setext2
156 }
157 } else {
158 HeadingStyle::Atx
160 }
161 }
162 HeadingStyle::SetextWithAtxClosed => {
163 if level <= 2 {
164 if level == 1 {
166 HeadingStyle::Setext1
167 } else {
168 HeadingStyle::Setext2
169 }
170 } else {
171 HeadingStyle::AtxClosed
173 }
174 }
175 _ => target_style,
176 };
177
178 if current_style != expected_style {
179 let fix = {
181 use crate::rules::heading_utils::HeadingUtils;
182
183 let converted_heading =
185 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
186
187 let line = line_info.content(ctx.content);
189 let original_indent = &line[..line_info.indent];
190 let final_heading = format!("{original_indent}{converted_heading}");
191
192 let converting_from_setext =
196 matches!(
197 heading.style,
198 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
199 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
200 let last_line = if converting_from_setext {
201 line_num + 2
202 } else {
203 line_num + 1
204 };
205
206 let start = ctx.line_content_byte_range(line_num + 1).start;
207 let end = ctx.line_content_byte_range(last_line).end;
208
209 Some(crate::rule::Fix::new(start..end, final_heading))
210 };
211
212 let (start_line, start_col, end_line, end_col) =
214 calculate_heading_range(line_num + 1, line_info.content(ctx.content));
215
216 result.push(LintWarning {
217 rule_name: Some(self.name().to_string()),
218 line: start_line,
219 column: start_col,
220 end_line,
221 end_column: end_col,
222 message: format!(
223 "Heading style should be {}, found {}",
224 match expected_style {
225 HeadingStyle::Atx => "# Heading",
226 HeadingStyle::AtxClosed => "# Heading #",
227 HeadingStyle::Setext1 => "Heading\n=======",
228 HeadingStyle::Setext2 => "Heading\n-------",
229 HeadingStyle::Consistent => "consistent with the first heading",
230 HeadingStyle::SetextWithAtx => "setext-with-atx style",
231 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
232 },
233 match current_style {
234 HeadingStyle::Atx => "# Heading",
235 HeadingStyle::AtxClosed => "# Heading #",
236 HeadingStyle::Setext1 => "Heading (underlined with =)",
237 HeadingStyle::Setext2 => "Heading (underlined with -)",
238 HeadingStyle::Consistent => "consistent style",
239 HeadingStyle::SetextWithAtx => "setext-with-atx style",
240 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
241 }
242 ),
243 severity: Severity::Warning,
244 fix,
245 });
246 }
247 }
248 }
249
250 Ok(result)
251 }
252
253 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
254 let warnings = self.check(ctx)?;
256 let warnings =
257 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
258
259 if warnings.is_empty() {
261 return Ok(ctx.content.to_string());
262 }
263
264 let mut fixes: Vec<_> = warnings
266 .iter()
267 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
268 .collect();
269 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
270
271 let mut result = ctx.content.to_string();
273 for (start, end, replacement) in fixes {
274 if start < result.len() && end <= result.len() && start <= end {
275 result.replace_range(start..end, replacement);
276 }
277 }
278
279 Ok(result)
280 }
281
282 fn category(&self) -> RuleCategory {
283 RuleCategory::Heading
284 }
285
286 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
287 if ctx.content.is_empty() || !ctx.likely_has_headings() {
289 return true;
290 }
291 !ctx.lines.iter().any(|line| line.heading.is_some())
293 }
294
295 fn as_any(&self) -> &dyn std::any::Any {
296 self
297 }
298
299 crate::impl_rule_config_methods!(MD003Config);
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use crate::lint_context::LintContext;
306
307 #[test]
308 fn test_atx_heading_style() {
309 let rule = MD003HeadingStyle::default();
310 let content = "# Heading 1\n## Heading 2\n### Heading 3";
311 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
312 let result = rule.check(&ctx).unwrap();
313 assert!(result.is_empty());
314 }
315
316 #[test]
317 fn test_setext_heading_style() {
318 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
319 let content = "Heading 1\n=========\n\nHeading 2\n---------";
320 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
321 let result = rule.check(&ctx).unwrap();
322 assert!(result.is_empty());
323 }
324
325 #[test]
326 fn test_front_matter() {
327 let rule = MD003HeadingStyle::default();
328 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
329
330 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
332 let result = rule.check(&ctx).unwrap();
333 assert!(
334 result.is_empty(),
335 "No warnings expected for content with front matter, found: {result:?}"
336 );
337 }
338
339 #[test]
340 fn test_consistent_heading_style() {
341 let rule = MD003HeadingStyle::default();
343 let content = "# Heading 1\n## Heading 2\n### Heading 3";
344 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
345 let result = rule.check(&ctx).unwrap();
346 assert!(result.is_empty());
347 }
348
349 #[test]
350 fn test_with_different_styles() {
351 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
353 let content = "# Heading 1\n## Heading 2\n### Heading 3";
354 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
355 let result = rule.check(&ctx).unwrap();
356
357 assert!(
359 result.is_empty(),
360 "No warnings expected for consistent ATX style, found: {result:?}"
361 );
362
363 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
365 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert!(
369 !result.is_empty(),
370 "Should have warnings for inconsistent heading styles"
371 );
372
373 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
375 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert!(
380 result.is_empty(),
381 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
382 );
383 }
384
385 #[test]
386 fn test_setext_with_atx_style() {
387 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
388 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
390 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert!(
393 result.is_empty(),
394 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
395 );
396
397 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
399 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
400 let result_wrong = rule.check(&ctx_wrong).unwrap();
401 assert_eq!(
402 result_wrong.len(),
403 2,
404 "Should flag ATX headings for h1/h2 with setext_with_atx style"
405 );
406 }
407
408 #[test]
409 fn test_fix_preserves_attribute_lists() {
410 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
412 let content = "# Heading { #custom-id .class } #";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414
415 let warnings = rule.check(&ctx).unwrap();
417 assert_eq!(warnings.len(), 1);
418 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
419 assert!(
420 fix.replacement.contains("{ #custom-id .class }"),
421 "check() fix should preserve attribute list, got: {}",
422 fix.replacement
423 );
424
425 let fixed = rule.fix(&ctx).unwrap();
427 assert!(
428 fixed.contains("{ #custom-id .class }"),
429 "fix() should preserve attribute list, got: {fixed}"
430 );
431 assert!(
432 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
433 "fix() should remove ATX closed trailing hashes, got: {fixed}"
434 );
435 }
436
437 #[test]
438 fn test_setext_with_atx_closed_style() {
439 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
440 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert!(
445 result.is_empty(),
446 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
447 );
448
449 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
451 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
452 let result_wrong = rule.check(&ctx_wrong).unwrap();
453 assert_eq!(
454 result_wrong.len(),
455 2,
456 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
457 );
458 }
459}