1use crate::rule::{LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::rule_config_serde::{FlavorOverrideNotice, option_is_explicit};
8use crate::rules::heading_utils::HeadingStyle;
9use crate::utils::range_utils::calculate_heading_range;
10use toml;
11
12mod md003_config;
13use md003_config::MD003Config;
14
15static MDG_STYLE_OVERRIDE: FlavorOverrideNotice = FlavorOverrideNotice::new();
17
18#[derive(Clone, Default)]
20pub struct MD003HeadingStyle {
21 config: MD003Config,
22 style_explicit: bool,
24}
25
26impl MD003HeadingStyle {
27 pub fn new(style: HeadingStyle) -> Self {
28 Self {
29 config: MD003Config { style },
30 style_explicit: true,
31 }
32 }
33
34 pub fn from_config_struct(config: MD003Config) -> Self {
35 Self {
36 config,
37 style_explicit: false,
38 }
39 }
40
41 fn is_consistent_mode(&self) -> bool {
43 self.config.style == HeadingStyle::Consistent
45 }
46
47 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
49 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
52 self.warn_once_about_overridden_style();
53 return HeadingStyle::Atx;
54 }
55
56 if !self.is_consistent_mode() {
57 return self.config.style;
58 }
59
60 let mut style_counts = std::collections::HashMap::new();
62
63 for line_info in &ctx.lines {
64 if let Some(heading) = &line_info.heading {
65 let style = match heading.style {
67 crate::lint_context::HeadingStyle::ATX => {
68 if heading.has_closing_sequence {
69 HeadingStyle::AtxClosed
70 } else {
71 HeadingStyle::Atx
72 }
73 }
74 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
75 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
76 };
77 *style_counts.entry(style).or_insert(0) += 1;
78 }
79 }
80
81 style_counts
84 .into_iter()
85 .max_by(|(style_a, count_a), (style_b, count_b)| {
86 match count_a.cmp(count_b) {
87 std::cmp::Ordering::Equal => {
88 let priority = |s: &HeadingStyle| match s {
90 HeadingStyle::Atx => 0,
91 HeadingStyle::Setext1 => 1,
92 HeadingStyle::Setext2 => 2,
93 HeadingStyle::AtxClosed => 3,
94 _ => 4,
95 };
96 priority(style_b).cmp(&priority(style_a)) }
98 other => other,
99 }
100 })
101 .map_or(HeadingStyle::Atx, |(style, _)| style)
102 }
103
104 fn warn_once_about_overridden_style(&self) {
108 if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
109 return;
110 }
111
112 let configured = self.config.style.to_string();
113 MDG_STYLE_OVERRIDE.report(
114 "MD003",
115 "style",
116 &configured,
117 "atx",
118 "Markdown with Gherkin recognizes structure headings only in plain ATX form",
119 );
120 }
121}
122
123impl Rule for MD003HeadingStyle {
124 fn name(&self) -> &'static str {
125 "MD003"
126 }
127
128 fn description(&self) -> &'static str {
129 "Heading style"
130 }
131
132 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
133 let mut result = Vec::new();
134
135 let target_style = self.get_target_style(ctx);
137
138 for (line_num, line_info) in ctx.lines.iter().enumerate() {
140 if let Some(heading) = &line_info.heading {
141 let level = heading.level;
142
143 let current_style = match heading.style {
145 crate::lint_context::HeadingStyle::ATX => {
146 if heading.has_closing_sequence {
147 HeadingStyle::AtxClosed
148 } else {
149 HeadingStyle::Atx
150 }
151 }
152 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
153 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
154 };
155
156 let expected_style = match target_style {
158 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
159 if level > 2 {
160 current_style
168 } else if level == 1 {
169 HeadingStyle::Setext1
170 } else {
171 HeadingStyle::Setext2
172 }
173 }
174 HeadingStyle::SetextWithAtx => {
175 if level <= 2 {
176 if level == 1 {
178 HeadingStyle::Setext1
179 } else {
180 HeadingStyle::Setext2
181 }
182 } else {
183 HeadingStyle::Atx
185 }
186 }
187 HeadingStyle::SetextWithAtxClosed => {
188 if level <= 2 {
189 if level == 1 {
191 HeadingStyle::Setext1
192 } else {
193 HeadingStyle::Setext2
194 }
195 } else {
196 HeadingStyle::AtxClosed
198 }
199 }
200 _ => target_style,
201 };
202
203 let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
209 HeadingStyle::Atx
210 } else {
211 expected_style
212 };
213
214 if current_style != expected_style {
215 let first_line_num = line_num + 2 - heading.text_lines;
218
219 let fix = {
221 use crate::rules::heading_utils::HeadingUtils;
222
223 let converted_heading =
225 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
226
227 let first_line_info = &ctx.lines[first_line_num - 1];
230 let first_line = first_line_info.content(ctx.content);
231 let original_indent = &first_line[..first_line_info.indent];
232 let final_heading = format!("{original_indent}{converted_heading}");
233
234 let converting_from_setext =
239 matches!(
240 heading.style,
241 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
242 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
243 let last_line = if converting_from_setext {
244 line_num + 2
245 } else {
246 line_num + 1
247 };
248
249 let start = ctx.line_content_byte_range(first_line_num).start;
250 let end = ctx.line_content_byte_range(last_line).end;
251
252 Some(crate::rule::Fix::new(start..end, final_heading))
253 };
254
255 let (start_line, start_col, end_line, end_col) =
257 calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
258
259 result.push(LintWarning {
260 rule_name: Some(self.name().to_string()),
261 line: start_line,
262 column: start_col,
263 end_line,
264 end_column: end_col,
265 message: format!(
266 "Heading style should be {}, found {}",
267 match expected_style {
268 HeadingStyle::Atx => "# Heading",
269 HeadingStyle::AtxClosed => "# Heading #",
270 HeadingStyle::Setext1 => "Heading\n=======",
271 HeadingStyle::Setext2 => "Heading\n-------",
272 HeadingStyle::Consistent => "consistent with the first heading",
273 HeadingStyle::SetextWithAtx => "setext-with-atx style",
274 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
275 },
276 match current_style {
277 HeadingStyle::Atx => "# Heading",
278 HeadingStyle::AtxClosed => "# Heading #",
279 HeadingStyle::Setext1 => "Heading (underlined with =)",
280 HeadingStyle::Setext2 => "Heading (underlined with -)",
281 HeadingStyle::Consistent => "consistent style",
282 HeadingStyle::SetextWithAtx => "setext-with-atx style",
283 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
284 }
285 ),
286 severity: Severity::Warning,
287 fix,
288 });
289 }
290 }
291 }
292
293 Ok(result)
294 }
295
296 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
297 let warnings = self.check(ctx)?;
299 let warnings =
300 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
301
302 if warnings.is_empty() {
304 return Ok(ctx.content.to_string());
305 }
306
307 let mut fixes: Vec<_> = warnings
309 .iter()
310 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
311 .collect();
312 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
313
314 let mut result = ctx.content.to_string();
316 for (start, end, replacement) in fixes {
317 if start < result.len() && end <= result.len() && start <= end {
318 result.replace_range(start..end, replacement);
319 }
320 }
321
322 Ok(result)
323 }
324
325 fn category(&self) -> RuleCategory {
326 RuleCategory::Heading
327 }
328
329 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
330 if ctx.content.is_empty() || !ctx.likely_has_headings() {
332 return true;
333 }
334 !ctx.lines.iter().any(|line| line.heading.is_some())
336 }
337
338 fn as_any(&self) -> &dyn std::any::Any {
339 self
340 }
341
342 crate::impl_rule_config_sections!(MD003Config);
343
344 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
345 where
346 Self: Sized,
347 {
348 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
349 let style_explicit = option_is_explicit(config, "MD003", "style");
350
351 Box::new(Self {
352 config: rule_config,
353 style_explicit,
354 })
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361 use crate::lint_context::LintContext;
362
363 #[test]
364 fn test_atx_heading_style() {
365 let rule = MD003HeadingStyle::default();
366 let content = "# Heading 1\n## Heading 2\n### Heading 3";
367 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
368 let result = rule.check(&ctx).unwrap();
369 assert!(result.is_empty());
370 }
371
372 #[test]
373 fn test_setext_heading_style() {
374 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
375 let content = "Heading 1\n=========\n\nHeading 2\n---------";
376 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
377 let result = rule.check(&ctx).unwrap();
378 assert!(result.is_empty());
379 }
380
381 #[test]
382 fn test_front_matter() {
383 let rule = MD003HeadingStyle::default();
384 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
385
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
388 let result = rule.check(&ctx).unwrap();
389 assert!(
390 result.is_empty(),
391 "No warnings expected for content with front matter, found: {result:?}"
392 );
393 }
394
395 #[test]
396 fn test_consistent_heading_style() {
397 let rule = MD003HeadingStyle::default();
399 let content = "# Heading 1\n## Heading 2\n### Heading 3";
400 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
401 let result = rule.check(&ctx).unwrap();
402 assert!(result.is_empty());
403 }
404
405 #[test]
406 fn test_with_different_styles() {
407 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
409 let content = "# Heading 1\n## Heading 2\n### Heading 3";
410 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
411 let result = rule.check(&ctx).unwrap();
412
413 assert!(
415 result.is_empty(),
416 "No warnings expected for consistent ATX style, found: {result:?}"
417 );
418
419 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
421 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
422 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
423 let result = rule.check(&ctx).unwrap();
424 assert!(
425 !result.is_empty(),
426 "Should have warnings for inconsistent heading styles"
427 );
428
429 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
431 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
432 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
433 let result = rule.check(&ctx).unwrap();
434 assert!(
436 result.is_empty(),
437 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
438 );
439 }
440
441 #[test]
442 fn test_setext_with_atx_style() {
443 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
444 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert!(
449 result.is_empty(),
450 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
451 );
452
453 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
455 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
456 let result_wrong = rule.check(&ctx_wrong).unwrap();
457 assert_eq!(
458 result_wrong.len(),
459 2,
460 "Should flag ATX headings for h1/h2 with setext_with_atx style"
461 );
462 }
463
464 #[test]
465 fn test_fix_preserves_attribute_lists() {
466 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
468 let content = "# Heading { #custom-id .class } #";
469 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
470
471 let warnings = rule.check(&ctx).unwrap();
473 assert_eq!(warnings.len(), 1);
474 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
475 assert!(
476 fix.replacement.contains("{ #custom-id .class }"),
477 "check() fix should preserve attribute list, got: {}",
478 fix.replacement
479 );
480
481 let fixed = rule.fix(&ctx).unwrap();
483 assert!(
484 fixed.contains("{ #custom-id .class }"),
485 "fix() should preserve attribute list, got: {fixed}"
486 );
487 assert!(
488 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
489 "fix() should remove ATX closed trailing hashes, got: {fixed}"
490 );
491 }
492
493 #[test]
494 fn test_setext_with_atx_closed_style() {
495 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
496 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(
501 result.is_empty(),
502 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
503 );
504
505 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
507 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
508 let result_wrong = rule.check(&ctx_wrong).unwrap();
509 assert_eq!(
510 result_wrong.len(),
511 2,
512 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
513 );
514 }
515
516 #[test]
517 fn test_mdg_steers_every_heading_to_plain_atx() {
518 let cases = [
521 (
522 MD003HeadingStyle::new(HeadingStyle::Atx),
523 "Checkout\n========\n\n## Scenario: Buy an item\n",
524 "# Checkout\n\n## Scenario: Buy an item\n",
525 ),
526 (
527 MD003HeadingStyle::new(HeadingStyle::AtxClosed),
528 "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
529 "# Feature: Checkout\n\n## Scenario: Buy an item\n",
530 ),
531 (
532 MD003HeadingStyle::new(HeadingStyle::Setext1),
533 "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
534 "# Feature: Checkout\n\n## Scenario: Documentation\n",
535 ),
536 (
537 MD003HeadingStyle::default(),
538 "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
539 "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
540 ),
541 ];
542
543 for (rule, content, expected) in cases {
544 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
545
546 assert!(!rule.should_skip(&mdg_ctx));
547 assert!(
548 !rule.check(&mdg_ctx).unwrap().is_empty(),
549 "MDG must still report non-ATX headings in {content:?}"
550 );
551 let fixed = rule.fix(&mdg_ctx).unwrap();
552 assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
553
554 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
555 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
556 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
557 }
558 }
559
560 #[test]
561 fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
562 let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
563 assert!(direct.style_explicit);
564
565 let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
566 style: HeadingStyle::Setext1,
567 });
568 assert!(!defaulted.style_explicit);
569
570 let mut config = crate::config::Config::default();
571 let mut rule_config = crate::config::RuleConfig::default();
572 rule_config
573 .values
574 .insert("style".to_string(), toml::Value::String("setext".to_string()));
575 config.rules.insert("MD003".to_string(), rule_config);
576 let configured = MD003HeadingStyle::from_config(&config);
577 let configured = configured
578 .as_any()
579 .downcast_ref::<MD003HeadingStyle>()
580 .expect("MD003::from_config builds MD003HeadingStyle");
581 assert!(configured.style_explicit);
582
583 let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
584 let default_configured = default_configured
585 .as_any()
586 .downcast_ref::<MD003HeadingStyle>()
587 .expect("MD003::from_config builds MD003HeadingStyle");
588 assert!(!default_configured.style_explicit);
589 }
590}