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 if !heading.is_valid {
67 continue;
68 }
69
70 let style = match heading.style {
72 crate::lint_context::HeadingStyle::ATX => {
73 if heading.has_closing_sequence {
74 HeadingStyle::AtxClosed
75 } else {
76 HeadingStyle::Atx
77 }
78 }
79 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
80 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
81 };
82 *style_counts.entry(style).or_insert(0) += 1;
83 }
84 }
85
86 style_counts
89 .into_iter()
90 .max_by(|(style_a, count_a), (style_b, count_b)| {
91 match count_a.cmp(count_b) {
92 std::cmp::Ordering::Equal => {
93 let priority = |s: &HeadingStyle| match s {
95 HeadingStyle::Atx => 0,
96 HeadingStyle::Setext1 => 1,
97 HeadingStyle::Setext2 => 2,
98 HeadingStyle::AtxClosed => 3,
99 _ => 4,
100 };
101 priority(style_b).cmp(&priority(style_a)) }
103 other => other,
104 }
105 })
106 .map_or(HeadingStyle::Atx, |(style, _)| style)
107 }
108
109 fn warn_once_about_overridden_style(&self) {
113 if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
114 return;
115 }
116
117 let configured = self.config.style.to_string();
118 MDG_STYLE_OVERRIDE.report(
119 "MD003",
120 "style",
121 &configured,
122 "atx",
123 "Markdown with Gherkin recognizes structure headings only in plain ATX form",
124 );
125 }
126}
127
128impl Rule for MD003HeadingStyle {
129 fn name(&self) -> &'static str {
130 "MD003"
131 }
132
133 fn description(&self) -> &'static str {
134 "Heading style"
135 }
136
137 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
138 let mut result = Vec::new();
139
140 let target_style = self.get_target_style(ctx);
142
143 for (line_num, line_info) in ctx.lines.iter().enumerate() {
145 if let Some(heading) = &line_info.heading {
146 if !heading.is_valid {
148 continue;
149 }
150
151 let level = heading.level;
152
153 let current_style = match heading.style {
155 crate::lint_context::HeadingStyle::ATX => {
156 if heading.has_closing_sequence {
157 HeadingStyle::AtxClosed
158 } else {
159 HeadingStyle::Atx
160 }
161 }
162 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
163 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
164 };
165
166 let expected_style = match target_style {
168 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
169 if level > 2 {
170 current_style
178 } else if level == 1 {
179 HeadingStyle::Setext1
180 } else {
181 HeadingStyle::Setext2
182 }
183 }
184 HeadingStyle::SetextWithAtx => {
185 if level <= 2 {
186 if level == 1 {
188 HeadingStyle::Setext1
189 } else {
190 HeadingStyle::Setext2
191 }
192 } else {
193 HeadingStyle::Atx
195 }
196 }
197 HeadingStyle::SetextWithAtxClosed => {
198 if level <= 2 {
199 if level == 1 {
201 HeadingStyle::Setext1
202 } else {
203 HeadingStyle::Setext2
204 }
205 } else {
206 HeadingStyle::AtxClosed
208 }
209 }
210 _ => target_style,
211 };
212
213 let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
219 HeadingStyle::Atx
220 } else {
221 expected_style
222 };
223
224 if current_style != expected_style {
225 let first_line_num = line_num + 2 - heading.text_lines;
228
229 let fix = {
231 use crate::rules::heading_utils::HeadingUtils;
232
233 let converted_heading =
235 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
236
237 let first_line_info = &ctx.lines[first_line_num - 1];
240 let first_line = first_line_info.content(ctx.content);
241 let original_indent = &first_line[..first_line_info.indent];
242 let final_heading = format!("{original_indent}{converted_heading}");
243
244 let converting_from_setext =
249 matches!(
250 heading.style,
251 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
252 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
253 let last_line = if converting_from_setext {
254 line_num + 2
255 } else {
256 line_num + 1
257 };
258
259 let start = ctx.line_content_byte_range(first_line_num).start;
260 let end = ctx.line_content_byte_range(last_line).end;
261
262 Some(crate::rule::Fix::new(start..end, final_heading))
263 };
264
265 let (start_line, start_col, end_line, end_col) =
267 calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
268
269 result.push(LintWarning {
270 rule_name: Some(self.name().to_string()),
271 line: start_line,
272 column: start_col,
273 end_line,
274 end_column: end_col,
275 message: format!(
276 "Heading style should be {}, found {}",
277 match expected_style {
278 HeadingStyle::Atx => "# Heading",
279 HeadingStyle::AtxClosed => "# Heading #",
280 HeadingStyle::Setext1 => "Heading\n=======",
281 HeadingStyle::Setext2 => "Heading\n-------",
282 HeadingStyle::Consistent => "consistent with the first heading",
283 HeadingStyle::SetextWithAtx => "setext-with-atx style",
284 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
285 },
286 match current_style {
287 HeadingStyle::Atx => "# Heading",
288 HeadingStyle::AtxClosed => "# Heading #",
289 HeadingStyle::Setext1 => "Heading (underlined with =)",
290 HeadingStyle::Setext2 => "Heading (underlined with -)",
291 HeadingStyle::Consistent => "consistent style",
292 HeadingStyle::SetextWithAtx => "setext-with-atx style",
293 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
294 }
295 ),
296 severity: Severity::Warning,
297 fix,
298 });
299 }
300 }
301 }
302
303 Ok(result)
304 }
305
306 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
307 let warnings = self.check(ctx)?;
309 let warnings =
310 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
311
312 if warnings.is_empty() {
314 return Ok(ctx.content.to_string());
315 }
316
317 let mut fixes: Vec<_> = warnings
319 .iter()
320 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
321 .collect();
322 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
323
324 let mut result = ctx.content.to_string();
326 for (start, end, replacement) in fixes {
327 if start < result.len() && end <= result.len() && start <= end {
328 result.replace_range(start..end, replacement);
329 }
330 }
331
332 Ok(result)
333 }
334
335 fn category(&self) -> RuleCategory {
336 RuleCategory::Heading
337 }
338
339 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
340 if ctx.content.is_empty() || !ctx.likely_has_headings() {
342 return true;
343 }
344 !ctx.lines.iter().any(|line| line.heading.is_some())
346 }
347
348 fn as_any(&self) -> &dyn std::any::Any {
349 self
350 }
351
352 crate::impl_rule_config_sections!(MD003Config);
353
354 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
355 where
356 Self: Sized,
357 {
358 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
359 let style_explicit = option_is_explicit(config, "MD003", "style");
360
361 Box::new(Self {
362 config: rule_config,
363 style_explicit,
364 })
365 }
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371 use crate::lint_context::LintContext;
372
373 #[test]
374 fn test_atx_heading_style() {
375 let rule = MD003HeadingStyle::default();
376 let content = "# Heading 1\n## Heading 2\n### Heading 3";
377 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
378 let result = rule.check(&ctx).unwrap();
379 assert!(result.is_empty());
380 }
381
382 #[test]
383 fn test_setext_heading_style() {
384 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
385 let content = "Heading 1\n=========\n\nHeading 2\n---------";
386 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
387 let result = rule.check(&ctx).unwrap();
388 assert!(result.is_empty());
389 }
390
391 #[test]
392 fn test_front_matter() {
393 let rule = MD003HeadingStyle::default();
394 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
395
396 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
398 let result = rule.check(&ctx).unwrap();
399 assert!(
400 result.is_empty(),
401 "No warnings expected for content with front matter, found: {result:?}"
402 );
403 }
404
405 #[test]
406 fn test_consistent_heading_style() {
407 let rule = MD003HeadingStyle::default();
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 assert!(result.is_empty());
413 }
414
415 #[test]
416 fn test_with_different_styles() {
417 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
419 let content = "# Heading 1\n## Heading 2\n### Heading 3";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422
423 assert!(
425 result.is_empty(),
426 "No warnings expected for consistent ATX style, found: {result:?}"
427 );
428
429 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
431 let content = "# Heading 1 #\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!(
435 !result.is_empty(),
436 "Should have warnings for inconsistent heading styles"
437 );
438
439 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
441 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
442 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
443 let result = rule.check(&ctx).unwrap();
444 assert!(
446 result.is_empty(),
447 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
448 );
449 }
450
451 #[test]
452 fn test_setext_with_atx_style() {
453 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
454 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
456 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
457 let result = rule.check(&ctx).unwrap();
458 assert!(
459 result.is_empty(),
460 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
461 );
462
463 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
465 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
466 let result_wrong = rule.check(&ctx_wrong).unwrap();
467 assert_eq!(
468 result_wrong.len(),
469 2,
470 "Should flag ATX headings for h1/h2 with setext_with_atx style"
471 );
472 }
473
474 #[test]
475 fn test_fix_preserves_attribute_lists() {
476 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
478 let content = "# Heading { #custom-id .class } #";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480
481 let warnings = rule.check(&ctx).unwrap();
483 assert_eq!(warnings.len(), 1);
484 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
485 assert!(
486 fix.replacement.contains("{ #custom-id .class }"),
487 "check() fix should preserve attribute list, got: {}",
488 fix.replacement
489 );
490
491 let fixed = rule.fix(&ctx).unwrap();
493 assert!(
494 fixed.contains("{ #custom-id .class }"),
495 "fix() should preserve attribute list, got: {fixed}"
496 );
497 assert!(
498 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
499 "fix() should remove ATX closed trailing hashes, got: {fixed}"
500 );
501 }
502
503 #[test]
504 fn test_setext_with_atx_closed_style() {
505 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
506 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
508 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
509 let result = rule.check(&ctx).unwrap();
510 assert!(
511 result.is_empty(),
512 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
513 );
514
515 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
517 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
518 let result_wrong = rule.check(&ctx_wrong).unwrap();
519 assert_eq!(
520 result_wrong.len(),
521 2,
522 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
523 );
524 }
525
526 #[test]
527 fn test_mdg_steers_every_heading_to_plain_atx() {
528 let cases = [
531 (
532 MD003HeadingStyle::new(HeadingStyle::Atx),
533 "Checkout\n========\n\n## Scenario: Buy an item\n",
534 "# Checkout\n\n## Scenario: Buy an item\n",
535 ),
536 (
537 MD003HeadingStyle::new(HeadingStyle::AtxClosed),
538 "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
539 "# Feature: Checkout\n\n## Scenario: Buy an item\n",
540 ),
541 (
542 MD003HeadingStyle::new(HeadingStyle::Setext1),
543 "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
544 "# Feature: Checkout\n\n## Scenario: Documentation\n",
545 ),
546 (
547 MD003HeadingStyle::default(),
548 "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
549 "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
550 ),
551 ];
552
553 for (rule, content, expected) in cases {
554 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
555
556 assert!(!rule.should_skip(&mdg_ctx));
557 assert!(
558 !rule.check(&mdg_ctx).unwrap().is_empty(),
559 "MDG must still report non-ATX headings in {content:?}"
560 );
561 let fixed = rule.fix(&mdg_ctx).unwrap();
562 assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
563
564 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
565 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
566 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
567 }
568 }
569
570 #[test]
571 fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
572 let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
573 assert!(direct.style_explicit);
574
575 let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
576 style: HeadingStyle::Setext1,
577 });
578 assert!(!defaulted.style_explicit);
579
580 let mut config = crate::config::Config::default();
581 let mut rule_config = crate::config::RuleConfig::default();
582 rule_config
583 .values
584 .insert("style".to_string(), toml::Value::String("setext".to_string()));
585 config.rules.insert("MD003".to_string(), rule_config);
586 let configured = MD003HeadingStyle::from_config(&config);
587 let configured = configured
588 .as_any()
589 .downcast_ref::<MD003HeadingStyle>()
590 .expect("MD003::from_config builds MD003HeadingStyle");
591 assert!(configured.style_explicit);
592
593 let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
594 let default_configured = default_configured
595 .as_any()
596 .downcast_ref::<MD003HeadingStyle>()
597 .expect("MD003::from_config builds MD003HeadingStyle");
598 assert!(!default_configured.style_explicit);
599 }
600}