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 fix = {
227 use crate::rules::heading_utils::HeadingUtils;
228
229 let converted_heading =
231 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
232
233 let line = line_info.content(ctx.content);
235 let original_indent = &line[..line_info.indent];
236 let final_heading = format!("{original_indent}{converted_heading}");
237
238 let converting_from_setext =
242 matches!(
243 heading.style,
244 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
245 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
246 let last_line = if converting_from_setext {
247 line_num + 2
248 } else {
249 line_num + 1
250 };
251
252 let start = ctx.line_content_byte_range(line_num + 1).start;
253 let end = ctx.line_content_byte_range(last_line).end;
254
255 Some(crate::rule::Fix::new(start..end, final_heading))
256 };
257
258 let (start_line, start_col, end_line, end_col) =
260 calculate_heading_range(line_num + 1, line_info.content(ctx.content));
261
262 result.push(LintWarning {
263 rule_name: Some(self.name().to_string()),
264 line: start_line,
265 column: start_col,
266 end_line,
267 end_column: end_col,
268 message: format!(
269 "Heading style should be {}, found {}",
270 match expected_style {
271 HeadingStyle::Atx => "# Heading",
272 HeadingStyle::AtxClosed => "# Heading #",
273 HeadingStyle::Setext1 => "Heading\n=======",
274 HeadingStyle::Setext2 => "Heading\n-------",
275 HeadingStyle::Consistent => "consistent with the first heading",
276 HeadingStyle::SetextWithAtx => "setext-with-atx style",
277 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
278 },
279 match current_style {
280 HeadingStyle::Atx => "# Heading",
281 HeadingStyle::AtxClosed => "# Heading #",
282 HeadingStyle::Setext1 => "Heading (underlined with =)",
283 HeadingStyle::Setext2 => "Heading (underlined with -)",
284 HeadingStyle::Consistent => "consistent style",
285 HeadingStyle::SetextWithAtx => "setext-with-atx style",
286 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
287 }
288 ),
289 severity: Severity::Warning,
290 fix,
291 });
292 }
293 }
294 }
295
296 Ok(result)
297 }
298
299 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
300 let warnings = self.check(ctx)?;
302 let warnings =
303 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
304
305 if warnings.is_empty() {
307 return Ok(ctx.content.to_string());
308 }
309
310 let mut fixes: Vec<_> = warnings
312 .iter()
313 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
314 .collect();
315 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
316
317 let mut result = ctx.content.to_string();
319 for (start, end, replacement) in fixes {
320 if start < result.len() && end <= result.len() && start <= end {
321 result.replace_range(start..end, replacement);
322 }
323 }
324
325 Ok(result)
326 }
327
328 fn category(&self) -> RuleCategory {
329 RuleCategory::Heading
330 }
331
332 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
333 if ctx.content.is_empty() || !ctx.likely_has_headings() {
335 return true;
336 }
337 !ctx.lines.iter().any(|line| line.heading.is_some())
339 }
340
341 fn as_any(&self) -> &dyn std::any::Any {
342 self
343 }
344
345 crate::impl_rule_config_sections!(MD003Config);
346
347 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
348 where
349 Self: Sized,
350 {
351 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
352 let style_explicit = option_is_explicit(config, "MD003", "style");
353
354 Box::new(Self {
355 config: rule_config,
356 style_explicit,
357 })
358 }
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364 use crate::lint_context::LintContext;
365
366 #[test]
367 fn test_atx_heading_style() {
368 let rule = MD003HeadingStyle::default();
369 let content = "# Heading 1\n## Heading 2\n### Heading 3";
370 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
371 let result = rule.check(&ctx).unwrap();
372 assert!(result.is_empty());
373 }
374
375 #[test]
376 fn test_setext_heading_style() {
377 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
378 let content = "Heading 1\n=========\n\nHeading 2\n---------";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381 assert!(result.is_empty());
382 }
383
384 #[test]
385 fn test_front_matter() {
386 let rule = MD003HeadingStyle::default();
387 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
388
389 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
391 let result = rule.check(&ctx).unwrap();
392 assert!(
393 result.is_empty(),
394 "No warnings expected for content with front matter, found: {result:?}"
395 );
396 }
397
398 #[test]
399 fn test_consistent_heading_style() {
400 let rule = MD003HeadingStyle::default();
402 let content = "# Heading 1\n## Heading 2\n### Heading 3";
403 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
404 let result = rule.check(&ctx).unwrap();
405 assert!(result.is_empty());
406 }
407
408 #[test]
409 fn test_with_different_styles() {
410 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
412 let content = "# Heading 1\n## Heading 2\n### Heading 3";
413 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
414 let result = rule.check(&ctx).unwrap();
415
416 assert!(
418 result.is_empty(),
419 "No warnings expected for consistent ATX style, found: {result:?}"
420 );
421
422 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
424 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert!(
428 !result.is_empty(),
429 "Should have warnings for inconsistent heading styles"
430 );
431
432 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
434 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
436 let result = rule.check(&ctx).unwrap();
437 assert!(
439 result.is_empty(),
440 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
441 );
442 }
443
444 #[test]
445 fn test_setext_with_atx_style() {
446 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
447 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert!(
452 result.is_empty(),
453 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
454 );
455
456 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
458 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
459 let result_wrong = rule.check(&ctx_wrong).unwrap();
460 assert_eq!(
461 result_wrong.len(),
462 2,
463 "Should flag ATX headings for h1/h2 with setext_with_atx style"
464 );
465 }
466
467 #[test]
468 fn test_fix_preserves_attribute_lists() {
469 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
471 let content = "# Heading { #custom-id .class } #";
472 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
473
474 let warnings = rule.check(&ctx).unwrap();
476 assert_eq!(warnings.len(), 1);
477 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
478 assert!(
479 fix.replacement.contains("{ #custom-id .class }"),
480 "check() fix should preserve attribute list, got: {}",
481 fix.replacement
482 );
483
484 let fixed = rule.fix(&ctx).unwrap();
486 assert!(
487 fixed.contains("{ #custom-id .class }"),
488 "fix() should preserve attribute list, got: {fixed}"
489 );
490 assert!(
491 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
492 "fix() should remove ATX closed trailing hashes, got: {fixed}"
493 );
494 }
495
496 #[test]
497 fn test_setext_with_atx_closed_style() {
498 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
499 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
501 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
502 let result = rule.check(&ctx).unwrap();
503 assert!(
504 result.is_empty(),
505 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
506 );
507
508 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
510 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
511 let result_wrong = rule.check(&ctx_wrong).unwrap();
512 assert_eq!(
513 result_wrong.len(),
514 2,
515 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
516 );
517 }
518
519 #[test]
520 fn test_mdg_steers_every_heading_to_plain_atx() {
521 let cases = [
524 (
525 MD003HeadingStyle::new(HeadingStyle::Atx),
526 "Checkout\n========\n\n## Scenario: Buy an item\n",
527 "# Checkout\n\n## Scenario: Buy an item\n",
528 ),
529 (
530 MD003HeadingStyle::new(HeadingStyle::AtxClosed),
531 "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
532 "# Feature: Checkout\n\n## Scenario: Buy an item\n",
533 ),
534 (
535 MD003HeadingStyle::new(HeadingStyle::Setext1),
536 "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
537 "# Feature: Checkout\n\n## Scenario: Documentation\n",
538 ),
539 (
540 MD003HeadingStyle::default(),
541 "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
542 "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
543 ),
544 ];
545
546 for (rule, content, expected) in cases {
547 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
548
549 assert!(!rule.should_skip(&mdg_ctx));
550 assert!(
551 !rule.check(&mdg_ctx).unwrap().is_empty(),
552 "MDG must still report non-ATX headings in {content:?}"
553 );
554 let fixed = rule.fix(&mdg_ctx).unwrap();
555 assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
556
557 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
558 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
559 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
560 }
561 }
562
563 #[test]
564 fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
565 let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
566 assert!(direct.style_explicit);
567
568 let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
569 style: HeadingStyle::Setext1,
570 });
571 assert!(!defaulted.style_explicit);
572
573 let mut config = crate::config::Config::default();
574 let mut rule_config = crate::config::RuleConfig::default();
575 rule_config
576 .values
577 .insert("style".to_string(), toml::Value::String("setext".to_string()));
578 config.rules.insert("MD003".to_string(), rule_config);
579 let configured = MD003HeadingStyle::from_config(&config);
580 let configured = configured
581 .as_any()
582 .downcast_ref::<MD003HeadingStyle>()
583 .expect("MD003::from_config builds MD003HeadingStyle");
584 assert!(configured.style_explicit);
585
586 let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
587 let default_configured = default_configured
588 .as_any()
589 .downcast_ref::<MD003HeadingStyle>()
590 .expect("MD003::from_config builds MD003HeadingStyle");
591 assert!(!default_configured.style_explicit);
592 }
593}