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 heading_is_in_literal_code(
48 ctx: &crate::lint_context::LintContext,
49 heading_idx: usize,
50 heading: &crate::lint_context::HeadingInfo,
51 ) -> bool {
52 let marker_idx = heading_idx
53 + usize::from(matches!(
54 heading.style,
55 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
56 ));
57 ctx.lines.get(marker_idx).is_some_and(|line| {
58 let marker_offset = line.byte_offset + line.indent;
59 ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset)
60 })
61 }
62
63 fn heading_style(
64 ctx: &crate::lint_context::LintContext,
65 heading_idx: usize,
66 heading: &crate::lint_context::HeadingInfo,
67 ) -> HeadingStyle {
68 match heading.style {
69 crate::lint_context::HeadingStyle::ATX => {
70 if !heading.has_closing_sequence {
71 return HeadingStyle::Atx;
72 }
73 let line = &ctx.lines[heading_idx];
74 let content = line.content(ctx.content);
75 let before_id = content
77 .rsplit_once(" {#")
78 .filter(|(_, id)| id.trim_end().ends_with('}'))
79 .map_or(content, |(text, _)| text);
80 let marker = line.byte_offset + before_id.trim_end().len() - heading.closing_sequence.len();
81 if ctx.is_inside_template_code(marker) || ctx.is_inside_mdx_code(marker) {
82 HeadingStyle::Atx
83 } else {
84 HeadingStyle::AtxClosed
85 }
86 }
87 crate::lint_context::HeadingStyle::Setext1 => HeadingStyle::Setext1,
88 crate::lint_context::HeadingStyle::Setext2 => HeadingStyle::Setext2,
89 }
90 }
91
92 fn conversion_is_safe(
93 ctx: &crate::lint_context::LintContext,
94 heading_idx: usize,
95 heading: &crate::lint_context::HeadingInfo,
96 ) -> bool {
97 if matches!(
98 heading.style,
99 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
100 ) {
101 return crate::rules::heading_utils::setext_to_atx_is_safe(ctx, heading_idx, heading);
102 }
103 let line = &ctx.lines[heading_idx];
104 let boundary = line.byte_offset + line.byte_len;
105 !ctx.is_inside_template_code(boundary) && !ctx.is_inside_mdx_code(boundary)
106 }
107
108 fn get_target_style(&self, ctx: &crate::lint_context::LintContext) -> HeadingStyle {
110 if ctx.flavor == crate::config::MarkdownFlavor::MDG {
113 self.warn_once_about_overridden_style();
114 return HeadingStyle::Atx;
115 }
116
117 if !self.is_consistent_mode() {
118 return self.config.style;
119 }
120
121 let mut style_counts = std::collections::HashMap::new();
123
124 for (line_num, line_info) in ctx.lines.iter().enumerate() {
125 if let Some(heading) = &line_info.heading {
126 if Self::heading_is_in_literal_code(ctx, line_num, heading) {
127 continue;
128 }
129 let style = Self::heading_style(ctx, line_num, heading);
131 *style_counts.entry(style).or_insert(0) += 1;
132 }
133 }
134
135 style_counts
138 .into_iter()
139 .max_by(|(style_a, count_a), (style_b, count_b)| {
140 match count_a.cmp(count_b) {
141 std::cmp::Ordering::Equal => {
142 let priority = |s: &HeadingStyle| match s {
144 HeadingStyle::Atx => 0,
145 HeadingStyle::Setext1 => 1,
146 HeadingStyle::Setext2 => 2,
147 HeadingStyle::AtxClosed => 3,
148 _ => 4,
149 };
150 priority(style_b).cmp(&priority(style_a)) }
152 other => other,
153 }
154 })
155 .map_or(HeadingStyle::Atx, |(style, _)| style)
156 }
157
158 fn warn_once_about_overridden_style(&self) {
162 if !self.style_explicit || matches!(self.config.style, HeadingStyle::Atx | HeadingStyle::Consistent) {
163 return;
164 }
165
166 let configured = self.config.style.to_string();
167 MDG_STYLE_OVERRIDE.report(
168 "MD003",
169 "style",
170 &configured,
171 "atx",
172 "Markdown with Gherkin recognizes structure headings only in plain ATX form",
173 );
174 }
175}
176
177impl Rule for MD003HeadingStyle {
178 fn name(&self) -> &'static str {
179 "MD003"
180 }
181
182 fn description(&self) -> &'static str {
183 "Heading style"
184 }
185
186 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
187 let mut result = Vec::new();
188
189 let target_style = self.get_target_style(ctx);
191
192 for (line_num, line_info) in ctx.lines.iter().enumerate() {
194 if let Some(heading) = &line_info.heading {
195 if Self::heading_is_in_literal_code(ctx, line_num, heading) {
196 continue;
197 }
198 let level = heading.level;
199
200 let current_style = Self::heading_style(ctx, line_num, heading);
202
203 let expected_style = match target_style {
205 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
206 if level > 2 {
207 current_style
215 } else if level == 1 {
216 HeadingStyle::Setext1
217 } else {
218 HeadingStyle::Setext2
219 }
220 }
221 HeadingStyle::SetextWithAtx => {
222 if level <= 2 {
223 if level == 1 {
225 HeadingStyle::Setext1
226 } else {
227 HeadingStyle::Setext2
228 }
229 } else {
230 HeadingStyle::Atx
232 }
233 }
234 HeadingStyle::SetextWithAtxClosed => {
235 if level <= 2 {
236 if level == 1 {
238 HeadingStyle::Setext1
239 } else {
240 HeadingStyle::Setext2
241 }
242 } else {
243 HeadingStyle::AtxClosed
245 }
246 }
247 _ => target_style,
248 };
249
250 let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
256 HeadingStyle::Atx
257 } else {
258 expected_style
259 };
260
261 if current_style != expected_style {
262 let first_line_num = line_num + 2 - heading.text_lines;
265
266 let fix = if Self::conversion_is_safe(ctx, line_num, heading) {
268 use crate::rules::heading_utils::HeadingUtils;
269
270 let converted_heading =
272 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
273
274 let first_line_info = &ctx.lines[first_line_num - 1];
277 let first_line = first_line_info.content(ctx.content);
278 let original_indent = &first_line[..first_line_info.indent];
279 let final_heading = format!("{original_indent}{converted_heading}");
280
281 let converting_from_setext =
286 matches!(
287 heading.style,
288 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
289 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
290 let last_line = if converting_from_setext {
291 line_num + 2
292 } else {
293 line_num + 1
294 };
295
296 let start = ctx.line_content_byte_range(first_line_num).start;
297 let end = ctx.line_content_byte_range(last_line).end;
298
299 Some(crate::rule::Fix::new(start..end, final_heading))
300 } else {
301 None
302 };
303
304 let (start_line, start_col, end_line, end_col) =
306 calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
307
308 result.push(LintWarning {
309 rule_name: Some(self.name().to_string()),
310 line: start_line,
311 column: start_col,
312 end_line,
313 end_column: end_col,
314 message: format!(
315 "Heading style should be {}, found {}",
316 match expected_style {
317 HeadingStyle::Atx => "# Heading",
318 HeadingStyle::AtxClosed => "# Heading #",
319 HeadingStyle::Setext1 => "Heading\n=======",
320 HeadingStyle::Setext2 => "Heading\n-------",
321 HeadingStyle::Consistent => "consistent with the first heading",
322 HeadingStyle::SetextWithAtx => "setext-with-atx style",
323 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
324 },
325 match current_style {
326 HeadingStyle::Atx => "# Heading",
327 HeadingStyle::AtxClosed => "# Heading #",
328 HeadingStyle::Setext1 => "Heading (underlined with =)",
329 HeadingStyle::Setext2 => "Heading (underlined with -)",
330 HeadingStyle::Consistent => "consistent style",
331 HeadingStyle::SetextWithAtx => "setext-with-atx style",
332 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
333 }
334 ),
335 severity: Severity::Warning,
336 fix,
337 });
338 }
339 }
340 }
341
342 Ok(result)
343 }
344
345 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
346 let warnings = self.check(ctx)?;
348 let warnings =
349 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
350
351 if warnings.is_empty() {
353 return Ok(ctx.content.to_string());
354 }
355
356 let mut fixes: Vec<_> = warnings
358 .iter()
359 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
360 .collect();
361 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
362
363 let mut result = ctx.content.to_string();
365 for (start, end, replacement) in fixes {
366 if start < result.len() && end <= result.len() && start <= end {
367 result.replace_range(start..end, replacement);
368 }
369 }
370
371 Ok(result)
372 }
373
374 fn category(&self) -> RuleCategory {
375 RuleCategory::Heading
376 }
377
378 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
379 if ctx.content.is_empty() || !ctx.likely_has_headings() {
381 return true;
382 }
383 !ctx.lines.iter().any(|line| line.heading.is_some())
385 }
386
387 fn as_any(&self) -> &dyn std::any::Any {
388 self
389 }
390
391 crate::impl_rule_config_sections!(MD003Config);
392
393 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
394 where
395 Self: Sized,
396 {
397 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
398 let style_explicit = option_is_explicit(config, "MD003", "style");
399
400 Box::new(Self {
401 config: rule_config,
402 style_explicit,
403 })
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::lint_context::LintContext;
411
412 #[test]
413 fn test_atx_heading_style() {
414 let rule = MD003HeadingStyle::default();
415 let content = "# Heading 1\n## Heading 2\n### Heading 3";
416 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
417 let result = rule.check(&ctx).unwrap();
418 assert!(result.is_empty());
419 }
420
421 #[test]
422 fn test_setext_heading_style() {
423 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
424 let content = "Heading 1\n=========\n\nHeading 2\n---------";
425 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
426 let result = rule.check(&ctx).unwrap();
427 assert!(result.is_empty());
428 }
429
430 #[test]
431 fn test_front_matter() {
432 let rule = MD003HeadingStyle::default();
433 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
434
435 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
437 let result = rule.check(&ctx).unwrap();
438 assert!(
439 result.is_empty(),
440 "No warnings expected for content with front matter, found: {result:?}"
441 );
442 }
443
444 #[test]
445 fn test_consistent_heading_style() {
446 let rule = MD003HeadingStyle::default();
448 let content = "# Heading 1\n## Heading 2\n### Heading 3";
449 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
450 let result = rule.check(&ctx).unwrap();
451 assert!(result.is_empty());
452 }
453
454 #[test]
455 fn test_with_different_styles() {
456 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
458 let content = "# Heading 1\n## Heading 2\n### Heading 3";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let result = rule.check(&ctx).unwrap();
461
462 assert!(
464 result.is_empty(),
465 "No warnings expected for consistent ATX style, found: {result:?}"
466 );
467
468 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
470 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
471 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
472 let result = rule.check(&ctx).unwrap();
473 assert!(
474 !result.is_empty(),
475 "Should have warnings for inconsistent heading styles"
476 );
477
478 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
480 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
481 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
482 let result = rule.check(&ctx).unwrap();
483 assert!(
485 result.is_empty(),
486 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
487 );
488 }
489
490 #[test]
491 fn test_setext_with_atx_style() {
492 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
493 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
495 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
496 let result = rule.check(&ctx).unwrap();
497 assert!(
498 result.is_empty(),
499 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
500 );
501
502 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
504 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
505 let result_wrong = rule.check(&ctx_wrong).unwrap();
506 assert_eq!(
507 result_wrong.len(),
508 2,
509 "Should flag ATX headings for h1/h2 with setext_with_atx style"
510 );
511 }
512
513 #[test]
514 fn test_fix_preserves_attribute_lists() {
515 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
517 let content = "# Heading { #custom-id .class } #";
518 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
519
520 let warnings = rule.check(&ctx).unwrap();
522 assert_eq!(warnings.len(), 1);
523 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
524 assert!(
525 fix.replacement.contains("{ #custom-id .class }"),
526 "check() fix should preserve attribute list, got: {}",
527 fix.replacement
528 );
529
530 let fixed = rule.fix(&ctx).unwrap();
532 assert!(
533 fixed.contains("{ #custom-id .class }"),
534 "fix() should preserve attribute list, got: {fixed}"
535 );
536 assert!(
537 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
538 "fix() should remove ATX closed trailing hashes, got: {fixed}"
539 );
540 }
541
542 #[test]
543 fn test_setext_with_atx_closed_style() {
544 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
545 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
547 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
548 let result = rule.check(&ctx).unwrap();
549 assert!(
550 result.is_empty(),
551 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
552 );
553
554 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
556 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
557 let result_wrong = rule.check(&ctx_wrong).unwrap();
558 assert_eq!(
559 result_wrong.len(),
560 2,
561 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
562 );
563 }
564
565 #[test]
566 fn test_mdg_steers_every_heading_to_plain_atx() {
567 let cases = [
570 (
571 MD003HeadingStyle::new(HeadingStyle::Atx),
572 "Checkout\n========\n\n## Scenario: Buy an item\n",
573 "# Checkout\n\n## Scenario: Buy an item\n",
574 ),
575 (
576 MD003HeadingStyle::new(HeadingStyle::AtxClosed),
577 "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
578 "# Feature: Checkout\n\n## Scenario: Buy an item\n",
579 ),
580 (
581 MD003HeadingStyle::new(HeadingStyle::Setext1),
582 "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
583 "# Feature: Checkout\n\n## Scenario: Documentation\n",
584 ),
585 (
586 MD003HeadingStyle::default(),
587 "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
588 "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
589 ),
590 ];
591
592 for (rule, content, expected) in cases {
593 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
594
595 assert!(!rule.should_skip(&mdg_ctx));
596 assert!(
597 !rule.check(&mdg_ctx).unwrap().is_empty(),
598 "MDG must still report non-ATX headings in {content:?}"
599 );
600 let fixed = rule.fix(&mdg_ctx).unwrap();
601 assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
602
603 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
604 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
605 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
606 }
607 }
608
609 #[test]
610 fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
611 let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
612 assert!(direct.style_explicit);
613
614 let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
615 style: HeadingStyle::Setext1,
616 });
617 assert!(!defaulted.style_explicit);
618
619 let mut config = crate::config::Config::default();
620 let mut rule_config = crate::config::RuleConfig::default();
621 rule_config
622 .values
623 .insert("style".to_string(), toml::Value::String("setext".to_string()));
624 config.rules.insert("MD003".to_string(), rule_config);
625 let configured = MD003HeadingStyle::from_config(&config);
626 let configured = configured
627 .as_any()
628 .downcast_ref::<MD003HeadingStyle>()
629 .expect("MD003::from_config builds MD003HeadingStyle");
630 assert!(configured.style_explicit);
631
632 let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
633 let default_configured = default_configured
634 .as_any()
635 .downcast_ref::<MD003HeadingStyle>()
636 .expect("MD003::from_config builds MD003HeadingStyle");
637 assert!(!default_configured.style_explicit);
638 }
639 #[test]
640 fn test_literal_headings_are_preserved_beside_visible_style_fixes() {
641 for (style, literal, visible, target) in [
642 (HeadingStyle::Atx, "\nFake\n---\n", "Visible\n---", "## Visible"),
643 (HeadingStyle::Atx, "\n# Fake #\n", "# Visible #", "# Visible"),
644 (HeadingStyle::AtxClosed, "\n# Fake\n", "# Visible", "# Visible #"),
645 (HeadingStyle::Setext1, "\n# Fake\n", "# Visible", "Visible\n======="),
646 ] {
647 let rule = MD003HeadingStyle::new(style);
648 for (flavor, code) in [
649 (
650 crate::config::MarkdownFlavor::Standard,
651 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
652 ),
653 (
654 crate::config::MarkdownFlavor::Hugo,
655 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
656 ),
657 ] {
658 for ending in ["\n", "\r\n"] {
659 let source = format!("{code}\n\n{visible}\n").replace('\n', ending);
660 let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
661 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
662 let warnings = rule.check(&ctx).unwrap();
663 assert_eq!(warnings.len(), 1, "{source}");
664 assert_eq!(
665 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
666 expected,
667 "{source}"
668 );
669 let direct_expected = format!("{}{ending}{ending}{target}{ending}", code.replace('\n', ending));
671 assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
672 }
673 }
674 }
675 }
676
677 #[test]
678 fn test_literal_heading_styles_do_not_choose_visible_style() {
679 let rule = MD003HeadingStyle::default();
680 for literal in ["# Fake #\n\n# Other #", "\nFake\n===\n\nOther\n===\n"] {
681 for (flavor, code) in [
682 (
683 crate::config::MarkdownFlavor::Standard,
684 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
685 ),
686 (
687 crate::config::MarkdownFlavor::Hugo,
688 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
689 ),
690 ] {
691 let source = format!("{code}\n\n# Visible\n");
692 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
693 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
694 assert_eq!(rule.fix(&ctx).unwrap(), source);
695 }
696 }
697 }
698
699 #[test]
700 fn test_dynamic_and_mdx_body_heading_styles_remain_fixable() {
701 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
702 for (flavor, source, target) in [
703 (
704 crate::config::MarkdownFlavor::Standard,
705 "# {{ title }}\n",
706 "# {{ title }} #\n",
707 ),
708 (
709 crate::config::MarkdownFlavor::MDX,
710 "<div>\n\n# Visible\n\n</div>\n",
711 "<div>\n\n# Visible #\n\n</div>\n",
712 ),
713 ] {
714 let ctx = crate::lint_context::LintContext::new(source, flavor, None);
715 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
716 assert_eq!(rule.fix(&ctx).unwrap(), target);
717 }
718 }
719
720 #[test]
721 fn test_unsafe_title_conversions_leave_safe_neighbors_fixable() {
722 for (style, source_title, adjacent, fixed_adjacent) in [
723 (
724 HeadingStyle::Setext1,
725 "# {% set unused=\"first \nlast\" %}{{ unused|length }}",
726 "# Adjacent",
727 "Adjacent\n========",
728 ),
729 (
730 HeadingStyle::Setext1,
731 "# {{< note title=`first \nlast` >}}",
732 "# Adjacent",
733 "Adjacent\n========",
734 ),
735 (
736 HeadingStyle::Atx,
737 "{% set unused=\"first \nlast\" %}{{ unused|length }}\n===",
738 "Adjacent\n===",
739 "# Adjacent",
740 ),
741 (
742 HeadingStyle::Atx,
743 "{{< note title=`first \nlast` >}}\n===",
744 "Adjacent\n===",
745 "# Adjacent",
746 ),
747 (
748 HeadingStyle::AtxClosed,
749 "# {% set unused=\"first \nlast\" %}{{ unused|length }}",
750 "# Adjacent",
751 "# Adjacent #",
752 ),
753 ] {
754 let rule = MD003HeadingStyle::new(style);
755 let flavor = if source_title.contains("{{<") {
756 crate::config::MarkdownFlavor::Hugo
757 } else {
758 crate::config::MarkdownFlavor::Standard
759 };
760 for ending in ["\n", "\r\n"] {
761 let source = format!("{source_title}\n\n{adjacent}\n").replace('\n', ending);
762 let expected = format!("{source_title}\n\n{fixed_adjacent}\n").replace('\n', ending);
763 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
764 let warnings = rule.check(&ctx).unwrap();
765 assert_eq!(warnings.len(), 2, "{source}");
766 assert!(warnings[0].fix.is_none(), "{source}");
767 assert!(warnings[1].fix.is_some());
768 assert_eq!(
769 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
770 expected
771 );
772 let direct_expected = format!(
773 "{}{ending}{ending}{fixed_adjacent}{ending}",
774 source_title.replace('\n', ending)
775 );
776 assert_eq!(rule.fix(&ctx).unwrap(), direct_expected);
777 }
778 }
779 }
780
781 #[test]
782 fn test_setext_style_conversion_preserves_literal_markup_and_hard_breaks() {
783 for title in [
784 "First\\\nSecond",
785 "First \nSecond",
786 "`first \nlast`",
787 "`first\n last`",
788 "<span title=\"first\nlast\">Visible</span>",
789 ] {
790 for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
791 let rule = MD003HeadingStyle::new(style);
792 let source = format!("{title}\n===\n\nAdjacent\n===\n");
793 let adjacent = if style == HeadingStyle::Atx {
794 "# Adjacent"
795 } else {
796 "# Adjacent #"
797 };
798 let expected = format!("{title}\n===\n\n{adjacent}\n");
799 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
800 let warnings = rule.check(&ctx).unwrap();
801 assert_eq!(warnings.len(), 2);
802 assert!(warnings[0].fix.is_none(), "{source}");
803 assert!(warnings[1].fix.is_some());
804 assert_eq!(rule.fix(&ctx).unwrap(), expected);
805 }
806 }
807 }
808
809 #[test]
810 fn test_safe_dynamic_and_setext_style_conversions_remain_fixable() {
811 for (style, source, expected) in [
812 (HeadingStyle::Setext1, "# {{ title }}\n", "{{ title }}\n===========\n"),
813 (HeadingStyle::AtxClosed, "# {{ title }}\n", "# {{ title }} #\n"),
814 (HeadingStyle::Atx, "`first\nlast`\n===\n", "# `first last`\n"),
815 ] {
816 let rule = MD003HeadingStyle::new(style);
817 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
818 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
819 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
820 assert_eq!(rule.fix(&ctx).unwrap(), expected);
821 }
822 }
823
824 #[test]
825 fn test_literal_closing_hashes_do_not_choose_heading_style() {
826 for (flavor, prefix, suffix) in [
827 (
828 crate::config::MarkdownFlavor::Standard,
829 "{% set unused=\"",
830 "\" %}{{ unused|length }}",
831 ),
832 (crate::config::MarkdownFlavor::Hugo, "{{< note title=`", "` >}}"),
833 ] {
834 for hashes in ["#", "###", "########"] {
835 for pad in ["", " ", "\t"] {
836 for ending in ["\n", "\r\n"] {
837 let title = format!("# {prefix}naïve {hashes}{pad}\nlast{suffix}");
838 let source = format!("{title}\n\n{title}\n\n# Adjacent\n").replace('\n', ending);
839 for style in [HeadingStyle::Atx, HeadingStyle::Consistent] {
840 let rule = MD003HeadingStyle::new(style);
841 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
842 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
843 assert_eq!(rule.fix(&ctx).unwrap(), source);
844 }
845 }
846 }
847 }
848 }
849 }
850
851 #[test]
852 fn test_literal_closing_hashes_still_require_a_real_closed_marker() {
853 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
854 let source = "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent\n";
855 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
856 let warnings = rule.check(&ctx).unwrap();
857 assert_eq!(warnings.len(), 2);
858 assert!(warnings[0].fix.is_none());
859 assert!(warnings[1].fix.is_some());
860 assert_eq!(
861 rule.fix(&ctx).unwrap(),
862 "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent #\n"
863 );
864 }
865
866 #[test]
867 fn test_real_closing_hashes_after_dynamic_titles_remain_closed() {
868 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
869 for (flavor, source) in [
870 (crate::config::MarkdownFlavor::Standard, "# {{ title }} ###\n"),
871 (crate::config::MarkdownFlavor::Standard, "# {{ title }} ### {#custom}\n"),
872 (crate::config::MarkdownFlavor::MDX, "<div>\n\n# Visible ###\n\n</div>\n"),
873 ] {
874 let ctx = crate::lint_context::LintContext::new(source, flavor, None);
875 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
876 assert_eq!(rule.fix(&ctx).unwrap(), source);
877 }
878 }
879
880 #[test]
881 fn test_multiline_inline_titles_survive_heading_style_conversion() {
882 for node in ["[Link]", "![Image]"] {
883 for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
884 for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
885 let rule = MD003HeadingStyle::new(style);
886 let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
887 let adjacent = if style == HeadingStyle::Atx {
888 "# Adjacent"
889 } else {
890 "# Adjacent #"
891 };
892 for ending in ["\n", "\r\n"] {
893 let source = format!("{title}\n\nAdjacent\n===\n").replace('\n', ending);
894 let expected = format!("{title}\n\n{adjacent}\n").replace('\n', ending);
895 let ctx = crate::lint_context::LintContext::new(
896 &source,
897 crate::config::MarkdownFlavor::Standard,
898 None,
899 );
900 let warnings = rule.check(&ctx).unwrap();
901 assert_eq!(warnings.len(), 2, "{source}");
902 assert!(warnings[0].fix.is_none());
903 assert!(warnings[1].fix.is_some());
904 assert_eq!(
905 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
906 expected
907 );
908 assert_eq!(rule.fix(&ctx).unwrap(), expected);
909 }
910 }
911 }
912 }
913 }
914
915 #[test]
916 fn test_link_labels_and_external_titles_remain_style_fixable() {
917 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
918 for (source, expected) in [
919 (
920 "[first\nlast](https://example.com \"single title\")\n===\n",
921 "# [first last](https://example.com \"single title\")\n",
922 ),
923 (
924 "[first\nlast][ref]\n===\n\n[ref]: https://example.com \"first\nlast\"\n",
925 "# [first last][ref]\n\n[ref]: https://example.com \"first\nlast\"\n",
926 ),
927 (
928 "![Image][ref]\n===\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
929 "# ![Image][ref]\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
930 ),
931 ] {
932 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
933 let warnings = rule.check(&ctx).unwrap();
934 assert_eq!(warnings.len(), 1);
935 assert!(warnings[0].fix.is_some(), "{source}");
936 assert_eq!(rule.fix(&ctx).unwrap(), expected);
937 }
938 }
939
940 #[test]
941 fn test_entity_newlines_do_not_block_safe_heading_style_joins() {
942 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
943 for entity in ["
", " ", "
"] {
944 for (title, joined) in [
945 (
946 format!("[first\nlast](https://example.com \"title{entity}line\")"),
947 format!("[first last](https://example.com \"title{entity}line\")"),
948 ),
949 (
950 format!(""),
951 format!(""),
952 ),
953 (
954 format!(
955 "[\nLabel](https://example.com \"link{entity}title\")"
956 ),
957 format!(
958 "[ Label](https://example.com \"link{entity}title\")"
959 ),
960 ),
961 ] {
962 for flavor in [
963 crate::config::MarkdownFlavor::Standard,
964 crate::config::MarkdownFlavor::MDX,
965 ] {
966 for ending in ["\n", "\r\n"] {
967 let source = format!("{title}\n===\n").replace('\n', ending);
968 let expected = format!("# {joined}\n").replace('\n', ending);
969 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
970 let warnings = rule.check(&ctx).unwrap();
971 assert_eq!(warnings.len(), 1, "{source}");
972 assert!(warnings[0].fix.is_some(), "{source}");
973 assert_eq!(rule.fix(&ctx).unwrap(), expected);
974 }
975 }
976 }
977 }
978 }
979
980 #[test]
981 fn test_mixed_inline_title_data_still_withholds_unsafe_heading_joins() {
982 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
983 let source = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\nAdjacent\n===\n";
984 let expected = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\n# Adjacent\n";
985 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
986 let warnings = rule.check(&ctx).unwrap();
987 assert_eq!(warnings.len(), 2);
988 assert!(warnings[0].fix.is_none());
989 assert!(warnings[1].fix.is_some());
990 assert_eq!(rule.fix(&ctx).unwrap(), expected);
991 }
992 #[test]
993 fn plain_atx_fixes_preserve_literal_terminal_hashes() {
994 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
995 for title in ["Title #", "标题 ###", "#######", "Title\t####", "**Title** ###"] {
996 let first_hash = title.len() - title.bytes().rev().take_while(|&byte| byte == b'#').count();
997 let escaped = format!("{}\\{}", &title[..first_hash], &title[first_hash..]);
998 for ending in ["\n", "\r\n"] {
999 for body in [format!("{title}\n===\n"), format!("# {title} #\n")] {
1000 let source = body.replace('\n', ending);
1001 let expected = format!("# {escaped}{ending}");
1002 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1003 let warnings = rule.check(&ctx).unwrap();
1004 assert_eq!(warnings.len(), 1, "{source}");
1005 assert_eq!(
1006 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1007 expected
1008 );
1009 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1010 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1011 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1012 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1013 }
1014 }
1015 }
1016 }
1017}