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 parsed in ctx.headings() {
126 let line_num = parsed.line_num - 1;
127 if Self::heading_is_in_literal_code(ctx, line_num, parsed.heading) {
128 continue;
129 }
130 let style = Self::heading_style(ctx, line_num, parsed.heading);
132 *style_counts.entry(style).or_insert(0) += 1;
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 native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
179 (self.config == Default::default()).then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(3))
180 }
181
182 fn name(&self) -> &'static str {
183 "MD003"
184 }
185
186 fn description(&self) -> &'static str {
187 "Heading style"
188 }
189
190 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
191 let mut result = Vec::new();
192
193 let target_style = self.get_target_style(ctx);
195
196 for parsed in ctx.headings() {
198 let line_num = parsed.line_num - 1;
199 let line_info = parsed.line_info;
200 let heading = parsed.heading;
201 if Self::heading_is_in_literal_code(ctx, line_num, heading) {
202 continue;
203 }
204 let level = heading.level;
205
206 let current_style = Self::heading_style(ctx, line_num, heading);
208
209 let expected_style = match target_style {
211 HeadingStyle::Setext1 | HeadingStyle::Setext2 => {
212 if level > 2 {
213 current_style
221 } else if level == 1 {
222 HeadingStyle::Setext1
223 } else {
224 HeadingStyle::Setext2
225 }
226 }
227 HeadingStyle::SetextWithAtx => {
228 if level <= 2 {
229 if level == 1 {
231 HeadingStyle::Setext1
232 } else {
233 HeadingStyle::Setext2
234 }
235 } else {
236 HeadingStyle::Atx
238 }
239 }
240 HeadingStyle::SetextWithAtxClosed => {
241 if level <= 2 {
242 if level == 1 {
244 HeadingStyle::Setext1
245 } else {
246 HeadingStyle::Setext2
247 }
248 } else {
249 HeadingStyle::AtxClosed
251 }
252 }
253 _ => target_style,
254 };
255
256 let expected_style = if ctx.flavor == crate::config::MarkdownFlavor::MDG {
262 HeadingStyle::Atx
263 } else {
264 expected_style
265 };
266
267 if current_style != expected_style {
268 let first_line_num = line_num + 2 - heading.text_lines;
271
272 let fix = if !parsed.is_blockquote() && Self::conversion_is_safe(ctx, line_num, heading) {
277 use crate::rules::heading_utils::HeadingUtils;
278
279 let converted_heading =
281 HeadingUtils::convert_heading_style(&heading.raw_text, level as u32, expected_style);
282
283 let first_line_info = &ctx.lines[first_line_num - 1];
287 let first_line = first_line_info.content(ctx.content);
288 let original_indent = &first_line[..heading.first_line_prefix_len()];
289
290 let atx_to_setext = heading.style == crate::lint_context::HeadingStyle::ATX
294 && level <= 2
295 && matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
296 let converted_heading = if atx_to_setext {
297 converted_heading.replacen('\n', &format!("\n{original_indent}"), 1)
298 } else {
299 converted_heading
300 };
301 let final_heading = format!("{original_indent}{converted_heading}");
302
303 let converting_from_setext =
308 matches!(
309 heading.style,
310 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
311 ) && !matches!(expected_style, HeadingStyle::Setext1 | HeadingStyle::Setext2);
312 let last_line = if converting_from_setext {
313 line_num + 2
314 } else {
315 line_num + 1
316 };
317
318 let start = ctx.line_content_byte_range(first_line_num).start;
319 let end = ctx.line_content_byte_range(last_line).end;
320
321 if atx_to_setext
325 && !crate::rules::heading_utils::atx_to_setext_is_safe(ctx, line_num, heading, &final_heading)
326 {
327 None
328 } else {
329 Some(crate::rule::Fix::new(start..end, final_heading))
330 }
331 } else {
332 None
333 };
334
335 let (start_line, start_col, end_line, end_col) =
337 calculate_heading_range(first_line_num, line_num + 1, line_info.content(ctx.content));
338
339 result.push(LintWarning {
340 rule_name: Some(self.name().to_string()),
341 line: start_line,
342 column: start_col,
343 end_line,
344 end_column: end_col,
345 message: format!(
346 "Heading style should be {}, found {}",
347 match expected_style {
348 HeadingStyle::Atx => "# Heading",
349 HeadingStyle::AtxClosed => "# Heading #",
350 HeadingStyle::Setext1 => "Heading\n=======",
351 HeadingStyle::Setext2 => "Heading\n-------",
352 HeadingStyle::Consistent => "consistent with the first heading",
353 HeadingStyle::SetextWithAtx => "setext-with-atx style",
354 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
355 },
356 match current_style {
357 HeadingStyle::Atx => "# Heading",
358 HeadingStyle::AtxClosed => "# Heading #",
359 HeadingStyle::Setext1 => "Heading (underlined with =)",
360 HeadingStyle::Setext2 => "Heading (underlined with -)",
361 HeadingStyle::Consistent => "consistent style",
362 HeadingStyle::SetextWithAtx => "setext-with-atx style",
363 HeadingStyle::SetextWithAtxClosed => "setext-with-atx-closed style",
364 }
365 ),
366 severity: Severity::Warning,
367 fix,
368 });
369 }
370 }
371
372 Ok(result)
373 }
374
375 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
376 let warnings = self.check(ctx)?;
378 let warnings =
379 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
380
381 if warnings.is_empty() {
383 return Ok(ctx.content.to_string());
384 }
385
386 let mut fixes: Vec<_> = warnings
388 .iter()
389 .filter_map(|w| w.fix.as_ref().map(|f| (f.range.start, f.range.end, &f.replacement)))
390 .collect();
391 fixes.sort_by_key(|f| std::cmp::Reverse(f.0));
392
393 let mut result = ctx.content.to_string();
395 for (start, end, replacement) in fixes {
396 if start < result.len() && end <= result.len() && start <= end {
397 result.replace_range(start..end, replacement);
398 }
399 }
400
401 Ok(result)
402 }
403
404 fn category(&self) -> RuleCategory {
405 RuleCategory::Heading
406 }
407
408 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
409 if ctx.content.is_empty() || !ctx.likely_has_headings() {
411 return true;
412 }
413 ctx.headings().next().is_none()
415 }
416
417 fn as_any(&self) -> &dyn std::any::Any {
418 self
419 }
420
421 crate::impl_rule_config_sections!(MD003Config);
422
423 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
424 where
425 Self: Sized,
426 {
427 let rule_config = crate::rule_config_serde::load_rule_config::<MD003Config>(config);
428 let style_explicit = option_is_explicit(config, "MD003", "style");
429
430 Box::new(Self {
431 config: rule_config,
432 style_explicit,
433 })
434 }
435}
436
437#[cfg(test)]
438mod tests {
439 use super::*;
440 use crate::lint_context::LintContext;
441
442 #[test]
443 fn test_atx_heading_style() {
444 let rule = MD003HeadingStyle::default();
445 let content = "# Heading 1\n## Heading 2\n### Heading 3";
446 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
447 let result = rule.check(&ctx).unwrap();
448 assert!(result.is_empty());
449 }
450
451 #[test]
452 fn test_setext_heading_style() {
453 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
454 let content = "Heading 1\n=========\n\nHeading 2\n---------";
455 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
456 let result = rule.check(&ctx).unwrap();
457 assert!(result.is_empty());
458 }
459
460 #[test]
461 fn test_front_matter() {
462 let rule = MD003HeadingStyle::default();
463 let content = "---\ntitle: Test\n---\n\n# Heading 1\n## Heading 2";
464
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
467 let result = rule.check(&ctx).unwrap();
468 assert!(
469 result.is_empty(),
470 "No warnings expected for content with front matter, found: {result:?}"
471 );
472 }
473
474 #[test]
475 fn test_consistent_heading_style() {
476 let rule = MD003HeadingStyle::default();
478 let content = "# Heading 1\n## Heading 2\n### Heading 3";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480 let result = rule.check(&ctx).unwrap();
481 assert!(result.is_empty());
482 }
483
484 #[test]
485 fn test_with_different_styles() {
486 let rule = MD003HeadingStyle::new(HeadingStyle::Consistent);
488 let content = "# Heading 1\n## Heading 2\n### Heading 3";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490 let result = rule.check(&ctx).unwrap();
491
492 assert!(
494 result.is_empty(),
495 "No warnings expected for consistent ATX style, found: {result:?}"
496 );
497
498 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
500 let content = "# Heading 1 #\nHeading 2\n-----\n### Heading 3";
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 "Should have warnings for inconsistent heading styles"
506 );
507
508 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
510 let content = "Heading 1\n=========\nHeading 2\n---------\n### Heading 3";
511 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
512 let result = rule.check(&ctx).unwrap();
513 assert!(
515 result.is_empty(),
516 "No warnings expected for setext style with ATX for level 3, found: {result:?}"
517 );
518 }
519
520 #[test]
521 fn test_setext_with_atx_style() {
522 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtx);
523 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3\n\n#### Heading 4";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526 let result = rule.check(&ctx).unwrap();
527 assert!(
528 result.is_empty(),
529 "SesetxtWithAtx style should accept setext for h1/h2 and ATX for h3+"
530 );
531
532 let content_wrong = "# Heading 1\n## Heading 2\n### Heading 3";
534 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
535 let result_wrong = rule.check(&ctx_wrong).unwrap();
536 assert_eq!(
537 result_wrong.len(),
538 2,
539 "Should flag ATX headings for h1/h2 with setext_with_atx style"
540 );
541 }
542
543 #[test]
544 fn test_fix_preserves_attribute_lists() {
545 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
547 let content = "# Heading { #custom-id .class } #";
548 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
549
550 let warnings = rule.check(&ctx).unwrap();
552 assert_eq!(warnings.len(), 1);
553 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
554 assert!(
555 fix.replacement.contains("{ #custom-id .class }"),
556 "check() fix should preserve attribute list, got: {}",
557 fix.replacement
558 );
559
560 let fixed = rule.fix(&ctx).unwrap();
562 assert!(
563 fixed.contains("{ #custom-id .class }"),
564 "fix() should preserve attribute list, got: {fixed}"
565 );
566 assert!(
567 !fixed.contains(" #\n") && !fixed.ends_with(" #"),
568 "fix() should remove ATX closed trailing hashes, got: {fixed}"
569 );
570 }
571
572 #[test]
573 fn test_setext_with_atx_closed_style() {
574 let rule = MD003HeadingStyle::new(HeadingStyle::SetextWithAtxClosed);
575 let content = "Heading 1\n=========\n\nHeading 2\n---------\n\n### Heading 3 ###\n\n#### Heading 4 ####";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578 let result = rule.check(&ctx).unwrap();
579 assert!(
580 result.is_empty(),
581 "SetextWithAtxClosed style should accept setext for h1/h2 and ATX closed for h3+"
582 );
583
584 let content_wrong = "Heading 1\n=========\n\n### Heading 3\n\n#### Heading 4";
586 let ctx_wrong = LintContext::new(content_wrong, crate::config::MarkdownFlavor::Standard, None);
587 let result_wrong = rule.check(&ctx_wrong).unwrap();
588 assert_eq!(
589 result_wrong.len(),
590 2,
591 "Should flag non-closed ATX headings for h3+ with setext_with_atx_closed style"
592 );
593 }
594
595 #[test]
596 fn test_mdg_steers_every_heading_to_plain_atx() {
597 let cases = [
600 (
601 MD003HeadingStyle::new(HeadingStyle::Atx),
602 "Checkout\n========\n\n## Scenario: Buy an item\n",
603 "# Checkout\n\n## Scenario: Buy an item\n",
604 ),
605 (
606 MD003HeadingStyle::new(HeadingStyle::AtxClosed),
607 "# Feature: Checkout\n\n## Scenario: Buy an item ##\n",
608 "# Feature: Checkout\n\n## Scenario: Buy an item\n",
609 ),
610 (
611 MD003HeadingStyle::new(HeadingStyle::Setext1),
612 "# Feature: Checkout\n\nScenario: Documentation\n-----------------------\n",
613 "# Feature: Checkout\n\n## Scenario: Documentation\n",
614 ),
615 (
616 MD003HeadingStyle::default(),
617 "Checkout\n========\n\nGuide\n-----\n\n## Scenario: Buy an item\n",
618 "# Checkout\n\n## Guide\n\n## Scenario: Buy an item\n",
619 ),
620 ];
621
622 for (rule, content, expected) in cases {
623 let mdg_ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
624
625 assert!(!rule.should_skip(&mdg_ctx));
626 assert!(
627 !rule.check(&mdg_ctx).unwrap().is_empty(),
628 "MDG must still report non-ATX headings in {content:?}"
629 );
630 let fixed = rule.fix(&mdg_ctx).unwrap();
631 assert_eq!(fixed, expected, "MDG must steer {content:?} to plain ATX");
632
633 let fixed_ctx = LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
634 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
635 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
636 }
637 }
638
639 #[test]
640 fn test_mdg_tracks_only_an_explicit_style_for_override_notices() {
641 let direct = MD003HeadingStyle::new(HeadingStyle::Setext1);
642 assert!(direct.style_explicit);
643
644 let defaulted = MD003HeadingStyle::from_config_struct(MD003Config {
645 style: HeadingStyle::Setext1,
646 });
647 assert!(!defaulted.style_explicit);
648
649 let mut config = crate::config::Config::default();
650 let mut rule_config = crate::config::RuleConfig::default();
651 rule_config
652 .values
653 .insert("style".to_string(), toml::Value::String("setext".to_string()));
654 config.rules.insert("MD003".to_string(), rule_config);
655 let configured = MD003HeadingStyle::from_config(&config);
656 let configured = configured
657 .as_any()
658 .downcast_ref::<MD003HeadingStyle>()
659 .expect("MD003::from_config builds MD003HeadingStyle");
660 assert!(configured.style_explicit);
661
662 let default_configured = MD003HeadingStyle::from_config(&crate::config::Config::default());
663 let default_configured = default_configured
664 .as_any()
665 .downcast_ref::<MD003HeadingStyle>()
666 .expect("MD003::from_config builds MD003HeadingStyle");
667 assert!(!default_configured.style_explicit);
668 }
669 #[test]
670 fn test_literal_headings_are_preserved_beside_visible_style_fixes() {
671 for (style, literal, visible, target) in [
672 (HeadingStyle::Atx, "\nFake\n---\n", "Visible\n---", "## Visible"),
673 (HeadingStyle::Atx, "\n# Fake #\n", "# Visible #", "# Visible"),
674 (HeadingStyle::AtxClosed, "\n# Fake\n", "# Visible", "# Visible #"),
675 (HeadingStyle::Setext1, "\n# Fake\n", "# Visible", "Visible\n======="),
676 ] {
677 let rule = MD003HeadingStyle::new(style);
678 for (flavor, code) in [
679 (
680 crate::config::MarkdownFlavor::Standard,
681 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
682 ),
683 (
684 crate::config::MarkdownFlavor::Hugo,
685 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
686 ),
687 ] {
688 for ending in ["\n", "\r\n"] {
689 let source = format!("{code}\n\n{visible}\n").replace('\n', ending);
690 let expected = format!("{code}\n\n{target}\n").replace('\n', ending);
691 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
692 let warnings = rule.check(&ctx).unwrap();
693 assert_eq!(warnings.len(), 1, "{source}");
694 assert_eq!(
695 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
696 expected,
697 "{source}"
698 );
699 let direct_expected = format!("{}{ending}{ending}{target}{ending}", code.replace('\n', ending));
701 assert_eq!(rule.fix(&ctx).unwrap(), direct_expected, "{source}");
702 }
703 }
704 }
705 }
706
707 #[test]
708 fn test_literal_heading_styles_do_not_choose_visible_style() {
709 let rule = MD003HeadingStyle::default();
710 for literal in ["# Fake #\n\n# Other #", "\nFake\n===\n\nOther\n===\n"] {
711 for (flavor, code) in [
712 (
713 crate::config::MarkdownFlavor::Standard,
714 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
715 ),
716 (
717 crate::config::MarkdownFlavor::Hugo,
718 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
719 ),
720 ] {
721 let source = format!("{code}\n\n# Visible\n");
722 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
723 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
724 assert_eq!(rule.fix(&ctx).unwrap(), source);
725 }
726 }
727 }
728
729 #[test]
730 fn test_dynamic_and_mdx_body_heading_styles_remain_fixable() {
731 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
732 for (flavor, source, target) in [
733 (
734 crate::config::MarkdownFlavor::Standard,
735 "# {{ title }}\n",
736 "# {{ title }} #\n",
737 ),
738 (
739 crate::config::MarkdownFlavor::MDX,
740 "<div>\n\n# Visible\n\n</div>\n",
741 "<div>\n\n# Visible #\n\n</div>\n",
742 ),
743 ] {
744 let ctx = crate::lint_context::LintContext::new(source, flavor, None);
745 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
746 assert_eq!(rule.fix(&ctx).unwrap(), target);
747 }
748 }
749
750 #[test]
751 fn test_unsafe_title_conversions_leave_safe_neighbors_fixable() {
752 for (style, source_title, adjacent, fixed_adjacent) in [
753 (
754 HeadingStyle::Setext1,
755 "# {% set unused=\"first \nlast\" %}{{ unused|length }}",
756 "# Adjacent",
757 "Adjacent\n========",
758 ),
759 (
760 HeadingStyle::Setext1,
761 "# {{< note title=`first \nlast` >}}",
762 "# Adjacent",
763 "Adjacent\n========",
764 ),
765 (
766 HeadingStyle::Atx,
767 "{% set unused=\"first \nlast\" %}{{ unused|length }}\n===",
768 "Adjacent\n===",
769 "# Adjacent",
770 ),
771 (
772 HeadingStyle::Atx,
773 "{{< note title=`first \nlast` >}}\n===",
774 "Adjacent\n===",
775 "# Adjacent",
776 ),
777 (
778 HeadingStyle::AtxClosed,
779 "# {% set unused=\"first \nlast\" %}{{ unused|length }}",
780 "# Adjacent",
781 "# Adjacent #",
782 ),
783 ] {
784 let rule = MD003HeadingStyle::new(style);
785 let flavor = if source_title.contains("{{<") {
786 crate::config::MarkdownFlavor::Hugo
787 } else {
788 crate::config::MarkdownFlavor::Standard
789 };
790 for ending in ["\n", "\r\n"] {
791 let source = format!("{source_title}\n\n{adjacent}\n").replace('\n', ending);
792 let expected = format!("{source_title}\n\n{fixed_adjacent}\n").replace('\n', ending);
793 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
794 let warnings = rule.check(&ctx).unwrap();
795 assert_eq!(warnings.len(), 2, "{source}");
796 assert!(warnings[0].fix.is_none(), "{source}");
797 assert!(warnings[1].fix.is_some());
798 assert_eq!(
799 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
800 expected
801 );
802 let direct_expected = format!(
803 "{}{ending}{ending}{fixed_adjacent}{ending}",
804 source_title.replace('\n', ending)
805 );
806 assert_eq!(rule.fix(&ctx).unwrap(), direct_expected);
807 }
808 }
809 }
810
811 #[test]
812 fn test_setext_style_conversion_preserves_literal_markup_and_hard_breaks() {
813 for title in [
814 "First\\\nSecond",
815 "First \nSecond",
816 "`first \nlast`",
817 "`first\n last`",
818 "<span title=\"first\nlast\">Visible</span>",
819 ] {
820 for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
821 let rule = MD003HeadingStyle::new(style);
822 let source = format!("{title}\n===\n\nAdjacent\n===\n");
823 let adjacent = if style == HeadingStyle::Atx {
824 "# Adjacent"
825 } else {
826 "# Adjacent #"
827 };
828 let expected = format!("{title}\n===\n\n{adjacent}\n");
829 let ctx = crate::lint_context::LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
830 let warnings = rule.check(&ctx).unwrap();
831 assert_eq!(warnings.len(), 2);
832 assert!(warnings[0].fix.is_none(), "{source}");
833 assert!(warnings[1].fix.is_some());
834 assert_eq!(rule.fix(&ctx).unwrap(), expected);
835 }
836 }
837 }
838
839 #[test]
840 fn test_safe_dynamic_and_setext_style_conversions_remain_fixable() {
841 for (style, source, expected) in [
842 (HeadingStyle::Setext1, "# {{ title }}\n", "{{ title }}\n===========\n"),
843 (HeadingStyle::AtxClosed, "# {{ title }}\n", "# {{ title }} #\n"),
844 (HeadingStyle::Atx, "`first\nlast`\n===\n", "# `first last`\n"),
845 ] {
846 let rule = MD003HeadingStyle::new(style);
847 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
848 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
849 assert!(rule.check(&ctx).unwrap()[0].fix.is_some());
850 assert_eq!(rule.fix(&ctx).unwrap(), expected);
851 }
852 }
853
854 #[test]
855 fn test_literal_closing_hashes_do_not_choose_heading_style() {
856 for (flavor, prefix, suffix) in [
857 (
858 crate::config::MarkdownFlavor::Standard,
859 "{% set unused=\"",
860 "\" %}{{ unused|length }}",
861 ),
862 (crate::config::MarkdownFlavor::Hugo, "{{< note title=`", "` >}}"),
863 ] {
864 for hashes in ["#", "###", "########"] {
865 for pad in ["", " ", "\t"] {
866 for ending in ["\n", "\r\n"] {
867 let title = format!("# {prefix}naïve {hashes}{pad}\nlast{suffix}");
868 let source = format!("{title}\n\n{title}\n\n# Adjacent\n").replace('\n', ending);
869 for style in [HeadingStyle::Atx, HeadingStyle::Consistent] {
870 let rule = MD003HeadingStyle::new(style);
871 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
872 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
873 assert_eq!(rule.fix(&ctx).unwrap(), source);
874 }
875 }
876 }
877 }
878 }
879 }
880
881 #[test]
882 fn test_literal_closing_hashes_still_require_a_real_closed_marker() {
883 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
884 let source = "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent\n";
885 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
886 let warnings = rule.check(&ctx).unwrap();
887 assert_eq!(warnings.len(), 2);
888 assert!(warnings[0].fix.is_none());
889 assert!(warnings[1].fix.is_some());
890 assert_eq!(
891 rule.fix(&ctx).unwrap(),
892 "# {% set unused=\"first ###\nlast\" %}{{ unused|length }}\n\n# Adjacent #\n"
893 );
894 }
895
896 #[test]
897 fn test_real_closing_hashes_after_dynamic_titles_remain_closed() {
898 let rule = MD003HeadingStyle::new(HeadingStyle::AtxClosed);
899 for (flavor, source) in [
900 (crate::config::MarkdownFlavor::Standard, "# {{ title }} ###\n"),
901 (crate::config::MarkdownFlavor::Standard, "# {{ title }} ### {#custom}\n"),
902 (crate::config::MarkdownFlavor::MDX, "<div>\n\n# Visible ###\n\n</div>\n"),
903 ] {
904 let ctx = crate::lint_context::LintContext::new(source, flavor, None);
905 assert!(rule.check(&ctx).unwrap().is_empty(), "{source}");
906 assert_eq!(rule.fix(&ctx).unwrap(), source);
907 }
908 }
909
910 #[test]
911 fn test_multiline_inline_titles_survive_heading_style_conversion() {
912 for node in ["[Link]", "![Image]"] {
913 for (open, close) in [('"', '"'), ('\'', '\''), ('(', ')')] {
914 for style in [HeadingStyle::Atx, HeadingStyle::AtxClosed] {
915 let rule = MD003HeadingStyle::new(style);
916 let title = format!("{node}(https://example.com {open}first\nlast{close})\n===");
917 let adjacent = if style == HeadingStyle::Atx {
918 "# Adjacent"
919 } else {
920 "# Adjacent #"
921 };
922 for ending in ["\n", "\r\n"] {
923 let source = format!("{title}\n\nAdjacent\n===\n").replace('\n', ending);
924 let expected = format!("{title}\n\n{adjacent}\n").replace('\n', ending);
925 let ctx = crate::lint_context::LintContext::new(
926 &source,
927 crate::config::MarkdownFlavor::Standard,
928 None,
929 );
930 let warnings = rule.check(&ctx).unwrap();
931 assert_eq!(warnings.len(), 2, "{source}");
932 assert!(warnings[0].fix.is_none());
933 assert!(warnings[1].fix.is_some());
934 assert_eq!(
935 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
936 expected
937 );
938 assert_eq!(rule.fix(&ctx).unwrap(), expected);
939 }
940 }
941 }
942 }
943 }
944
945 #[test]
946 fn test_link_labels_and_external_titles_remain_style_fixable() {
947 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
948 for (source, expected) in [
949 (
950 "[first\nlast](https://example.com \"single title\")\n===\n",
951 "# [first last](https://example.com \"single title\")\n",
952 ),
953 (
954 "[first\nlast][ref]\n===\n\n[ref]: https://example.com \"first\nlast\"\n",
955 "# [first last][ref]\n\n[ref]: https://example.com \"first\nlast\"\n",
956 ),
957 (
958 "![Image][ref]\n===\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
959 "# ![Image][ref]\n\n[ref]: https://example.com/i.png \"first\nlast\"\n",
960 ),
961 ] {
962 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
963 let warnings = rule.check(&ctx).unwrap();
964 assert_eq!(warnings.len(), 1);
965 assert!(warnings[0].fix.is_some(), "{source}");
966 assert_eq!(rule.fix(&ctx).unwrap(), expected);
967 }
968 }
969
970 #[test]
971 fn test_entity_newlines_do_not_block_safe_heading_style_joins() {
972 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
973 for entity in ["
", " ", "
"] {
974 for (title, joined) in [
975 (
976 format!("[first\nlast](https://example.com \"title{entity}line\")"),
977 format!("[first last](https://example.com \"title{entity}line\")"),
978 ),
979 (
980 format!(""),
981 format!(""),
982 ),
983 (
984 format!(
985 "[\nLabel](https://example.com \"link{entity}title\")"
986 ),
987 format!(
988 "[ Label](https://example.com \"link{entity}title\")"
989 ),
990 ),
991 ] {
992 for flavor in [
993 crate::config::MarkdownFlavor::Standard,
994 crate::config::MarkdownFlavor::MDX,
995 ] {
996 for ending in ["\n", "\r\n"] {
997 let source = format!("{title}\n===\n").replace('\n', ending);
998 let expected = format!("# {joined}\n").replace('\n', ending);
999 let ctx = crate::lint_context::LintContext::new(&source, flavor, None);
1000 let warnings = rule.check(&ctx).unwrap();
1001 assert_eq!(warnings.len(), 1, "{source}");
1002 assert!(warnings[0].fix.is_some(), "{source}");
1003 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1004 }
1005 }
1006 }
1007 }
1008 }
1009
1010 #[test]
1011 fn test_mixed_inline_title_data_still_withholds_unsafe_heading_joins() {
1012 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
1013 let source = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\nAdjacent\n===\n";
1014 let expected = "[Entity](https://example.com \"title
line\") [Raw](https://example.com \"first\nlast\")\n===\n\n# Adjacent\n";
1015 let ctx = crate::lint_context::LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
1016 let warnings = rule.check(&ctx).unwrap();
1017 assert_eq!(warnings.len(), 2);
1018 assert!(warnings[0].fix.is_none());
1019 assert!(warnings[1].fix.is_some());
1020 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1021 }
1022 #[test]
1023 fn plain_atx_fixes_preserve_literal_terminal_hashes() {
1024 let rule = MD003HeadingStyle::new(HeadingStyle::Atx);
1025 for title in ["Title #", "标题 ###", "#######", "Title\t####", "**Title** ###"] {
1026 let first_hash = title.len() - title.bytes().rev().take_while(|&byte| byte == b'#').count();
1027 let escaped = format!("{}\\{}", &title[..first_hash], &title[first_hash..]);
1028 for ending in ["\n", "\r\n"] {
1029 for body in [format!("{title}\n===\n"), format!("# {title} #\n")] {
1030 let source = body.replace('\n', ending);
1031 let expected = format!("# {escaped}{ending}");
1032 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
1033 let warnings = rule.check(&ctx).unwrap();
1034 assert_eq!(warnings.len(), 1, "{source}");
1035 assert_eq!(
1036 crate::utils::fix_utils::apply_warning_fixes(&source, &warnings).unwrap(),
1037 expected
1038 );
1039 assert_eq!(rule.fix(&ctx).unwrap(), expected);
1040 let fixed_ctx = LintContext::new(&expected, crate::config::MarkdownFlavor::Standard, None);
1041 assert!(rule.check(&fixed_ctx).unwrap().is_empty());
1042 assert_eq!(rule.fix(&fixed_ctx).unwrap(), expected);
1043 }
1044 }
1045 }
1046 }
1047
1048 mod atx_to_setext_safety {
1049 use super::*;
1050 use crate::config::MarkdownFlavor;
1051 use crate::utils::fix_utils::apply_warning_fixes;
1052
1053 fn headings(content: &str) -> Vec<(u8, String)> {
1055 use pulldown_cmark::{Event, Parser, Tag, TagEnd};
1056 let mut out = Vec::new();
1057 let mut current: Option<(u8, String)> = None;
1058 for event in Parser::new_ext(content, crate::utils::parser_options::rumdl_parser_options_for(content)) {
1059 match event {
1060 Event::Start(Tag::Heading { level, .. }) => current = Some((level as u8, String::new())),
1061 Event::End(TagEnd::Heading(_)) => out.extend(current.take()),
1062 Event::Text(t) | Event::Code(t) => {
1063 if let Some((_, text)) = current.as_mut() {
1064 text.push_str(&t);
1065 }
1066 }
1067 _ => {}
1068 }
1069 }
1070 out
1071 }
1072
1073 fn run(style: HeadingStyle, flavor: MarkdownFlavor, src: &str) -> (Vec<LintWarning>, String, String) {
1074 let rule = MD003HeadingStyle::new(style);
1075 let ctx = LintContext::new(src, flavor, None);
1076 let warnings = rule.check(&ctx).unwrap();
1077 let by_warning = apply_warning_fixes(src, &warnings).unwrap();
1078 let by_fix = rule.fix(&ctx).unwrap();
1079 (warnings, by_warning, by_fix)
1080 }
1081
1082 fn assert_declined_with(style: HeadingStyle, flavor: MarkdownFlavor, src: &str) {
1083 let (warnings, by_warning, by_fix) = run(style, flavor, src);
1084 assert!(!warnings.is_empty(), "{src:?}: expected a warning to remain");
1085 assert!(
1086 warnings.iter().all(|w| w.fix.is_none()),
1087 "{src:?}: expected no fix, got {warnings:?}"
1088 );
1089 assert_eq!(by_warning, src, "{src:?}: warning fixes changed the document");
1090 assert_eq!(by_fix, src, "{src:?}: fix() changed the document");
1091 }
1092
1093 fn assert_declined(src: &str) {
1094 assert_declined_with(HeadingStyle::Setext1, MarkdownFlavor::Standard, src);
1095 }
1096
1097 fn assert_converted_with(style: HeadingStyle, flavor: MarkdownFlavor, src: &str, expected: &str) {
1098 let (warnings, by_warning, by_fix) = run(style, flavor, src);
1099 assert!(!warnings.is_empty(), "{src:?}: expected a warning");
1100 assert_eq!(by_warning, expected, "{src:?}: warning fixes");
1101 assert_eq!(by_fix, expected, "{src:?}: fix()");
1102 assert_eq!(
1103 headings(src),
1104 headings(expected),
1105 "{src:?}: converting changed the heading set"
1106 );
1107 }
1108
1109 fn assert_converted(src: &str, expected: &str) {
1110 assert_converted_with(HeadingStyle::Setext1, MarkdownFlavor::Standard, src, expected);
1111 }
1112
1113 #[test]
1114 fn paragraph_above_is_not_absorbed() {
1115 assert_declined("para\n# H\n");
1116 assert_declined("para\n## H\n");
1117 assert_declined("para\n# H #\n");
1118 assert_declined("a\n# H\nb\n");
1119 assert_declined("para\r\n# H\r\n");
1120 }
1121
1122 #[test]
1123 fn list_and_table_above_decline() {
1124 assert_declined("- item\n# H\n");
1125 assert_declined("- item\n # H\n");
1126 assert_declined("| a | b |\n|---|---|\n| 1 | 2 |\n# H\n");
1127 }
1128
1129 #[test]
1130 fn meta_styles_decline_with_paragraph_above() {
1131 assert_declined_with(HeadingStyle::SetextWithAtx, MarkdownFlavor::Standard, "para\n# H\n");
1132 assert_declined_with(
1133 HeadingStyle::SetextWithAtxClosed,
1134 MarkdownFlavor::Standard,
1135 "para\n# H\n",
1136 );
1137 }
1138
1139 #[test]
1140 fn empty_heading_is_not_deleted() {
1141 assert_declined("para\n#\n");
1142 assert_declined("\n#\n");
1143 assert_declined("\n##\n");
1144 assert_declined("# \n");
1145 }
1146
1147 #[test]
1148 fn heading_text_that_is_block_syntax_declines() {
1149 for text in [
1150 "- a",
1151 "+ x",
1152 "* x",
1153 "1.",
1154 "1) x",
1155 ">",
1156 "> q",
1157 "---",
1158 "***",
1159 "```js",
1160 "~~~",
1161 "<div>",
1162 "<!-- c -->",
1163 "[x]: /u",
1164 ] {
1165 assert_declined(&format!("\n# {text}\n"));
1166 }
1167 assert_declined("\n## -\n");
1168 }
1169
1170 #[test]
1171 fn list_continuation_underline_keeps_the_container_indent() {
1172 assert_converted("- a\n\n # H\n", "- a\n\n H\n =\n");
1173 assert_converted("1. a\n\n ## H\n", "1. a\n\n H\n -\n");
1174 assert_converted("\n # H\n", "\n H\n =\n");
1175 }
1176
1177 #[test]
1178 fn blockquote_heading_is_left_alone() {
1179 assert_declined("> # H\n");
1180 }
1181
1182 #[test]
1183 fn safe_positions_still_convert() {
1184 assert_converted(
1185 "# Heading 1\n## Heading 2\n",
1186 "Heading 1\n=========\nHeading 2\n---------\n",
1187 );
1188 assert_converted("# A\n# B\n", "A\n=\nB\n=\n");
1189 assert_converted("# H\n", "H\n=\n");
1190 assert_converted("para\n\n# H\n", "para\n\nH\n=\n");
1191 assert_converted("---\n\n# H\n", "---\n\nH\n=\n");
1192 assert_converted("***\n# H\n", "***\nH\n=\n");
1193 assert_converted("```\ncode\n```\n# H\n", "```\ncode\n```\nH\n=\n");
1194 assert_converted("Foo\n===\n# H\n", "Foo\n===\nH\n=\n");
1195 assert_converted("---\ntitle: x\n---\n# H\n", "---\ntitle: x\n---\nH\n=\n");
1196 assert_converted("# H #\n", "H\n=\n");
1197 assert_converted("para\n\n## H\n", "para\n\nH\n-\n");
1198 }
1199
1200 #[test]
1201 fn setext_with_atx_converts_only_levels_one_and_two() {
1202 assert_converted_with(
1203 HeadingStyle::SetextWithAtx,
1204 MarkdownFlavor::Standard,
1205 "# A\n## B\n### C\n",
1206 "A\n=\nB\n-\n### C\n",
1207 );
1208 assert_converted_with(
1209 HeadingStyle::SetextWithAtx,
1210 MarkdownFlavor::Standard,
1211 "\n# A #\n",
1212 "\nA\n=\n",
1213 );
1214 }
1215
1216 #[test]
1217 fn levels_three_and_up_stay_atx() {
1218 let (warnings, by_warning, _) = run(HeadingStyle::Setext1, MarkdownFlavor::Standard, "para\n### H\n");
1219 assert!(warnings.is_empty());
1220 assert_eq!(by_warning, "para\n### H\n");
1221 }
1222
1223 #[test]
1224 fn text_that_only_looks_like_syntax_converts() {
1225 assert_converted("\n# ===\n", "\n===\n===\n");
1226 assert_converted("\n# \\-\n", "\n\\-\n==\n");
1227 assert_converted("\n# foo\\\n", "\nfoo\\\n====\n");
1228 assert_converted("\n# ::: note\n", "\n::: note\n========\n");
1229 assert_converted("\n# Normal title\n", "\nNormal title\n============\n");
1230 }
1231
1232 #[test]
1233 fn flavor_block_syntax_declines() {
1234 for (flavor, text) in [
1235 (MarkdownFlavor::Pandoc, "::: note"),
1236 (MarkdownFlavor::Quarto, "::: note"),
1237 (MarkdownFlavor::MkDocs, "!!! note"),
1238 (MarkdownFlavor::MkDocs, "??? note"),
1239 (MarkdownFlavor::MkDocs, "=== \"Tab\""),
1240 (MarkdownFlavor::MDX, "<Foo />"),
1241 (MarkdownFlavor::MDX, "import x from 'y'"),
1242 (MarkdownFlavor::MDX, "{expr}"),
1243 (MarkdownFlavor::Quarto, "$$"),
1244 (MarkdownFlavor::Pandoc, "$$ x"),
1245 (MarkdownFlavor::Standard, "+++"),
1246 ] {
1247 let src = format!("\n# {text}\n");
1248 let (_, by_warning, by_fix) = run(HeadingStyle::Setext1, flavor, &src);
1249 assert_eq!(
1250 by_warning, src,
1251 "{flavor:?} {text:?}: warning fixes changed the document"
1252 );
1253 assert_eq!(by_fix, src, "{flavor:?} {text:?}: fix() changed the document");
1254 }
1255 }
1256
1257 #[test]
1258 fn fixed_output_keeps_headings_for_every_flavor() {
1259 for flavor in [
1260 MarkdownFlavor::Standard,
1261 MarkdownFlavor::Pandoc,
1262 MarkdownFlavor::Quarto,
1263 MarkdownFlavor::MkDocs,
1264 MarkdownFlavor::MDX,
1265 ] {
1266 let src = "\n# Plain title\n\n## Second\n";
1267 let (_, by_warning, _) = run(HeadingStyle::Setext1, flavor, src);
1268 assert_eq!(headings(src), headings(&by_warning), "{flavor:?}");
1269 assert!(by_warning.contains("Plain title\n===="), "{flavor:?}: {by_warning:?}");
1270 }
1271 }
1272 }
1273 mod quoted_headings {
1274 use super::*;
1275 use crate::config::MarkdownFlavor;
1276
1277 fn check_with(style: HeadingStyle, src: &str) -> Vec<LintWarning> {
1278 let ctx = LintContext::new(src, MarkdownFlavor::Standard, None);
1279 MD003HeadingStyle::new(style).check(&ctx).unwrap()
1280 }
1281
1282 fn lines_of(warnings: &[LintWarning]) -> Vec<usize> {
1283 warnings.iter().map(|w| w.line).collect()
1284 }
1285
1286 fn assert_unchanged_by_fix(style: HeadingStyle, src: &str) {
1287 let ctx = LintContext::new(src, MarkdownFlavor::Standard, None);
1288 let rule = MD003HeadingStyle::new(style);
1289 assert_eq!(rule.fix(&ctx).unwrap(), src, "{src:?}: fix() changed the document");
1290 }
1291
1292 #[test]
1293 fn quoted_headings_are_judged_against_the_consistent_style() {
1294 let src = "Top\n===\n\n> ## Quote atx\n>\n> Setext in quote\n> ---\n";
1297 let warnings = check_with(HeadingStyle::Consistent, src);
1298 assert_eq!(lines_of(&warnings), vec![1, 6], "{warnings:?}");
1299 assert!(warnings[0].fix.is_some());
1300 assert!(warnings[1].fix.is_none());
1301
1302 let src = "Top\n===\n\nNext\n====\n\n> ## Quote atx\n";
1304 let warnings = check_with(HeadingStyle::Consistent, src);
1305 assert_eq!(lines_of(&warnings), vec![7], "{warnings:?}");
1306 assert!(warnings[0].fix.is_none());
1307 assert_unchanged_by_fix(HeadingStyle::Consistent, "> ## Quote atx\n\nTop\n===\n\nNext\n====\n");
1308 }
1309
1310 #[test]
1311 fn quoted_setext_heading_is_flagged_in_an_atx_document() {
1312 let src = "# Top\n\n> Setext in quote\n> ---\n";
1313 let warnings = check_with(HeadingStyle::Consistent, src);
1314 assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1316 assert!(warnings[0].message.contains("# Heading"), "{}", warnings[0].message);
1317 assert!(warnings.iter().all(|w| w.fix.is_none()));
1318 assert_unchanged_by_fix(HeadingStyle::Consistent, src);
1319 }
1320
1321 #[test]
1322 fn unquoted_setext_in_the_same_shape_is_flagged_too() {
1323 let warnings = check_with(HeadingStyle::Consistent, "# Top\n\nPlain\n---\n");
1324 assert_eq!(lines_of(&warnings), vec![3]);
1325 }
1326
1327 #[test]
1328 fn nested_quotes_are_checked() {
1329 let src = "# Top\n\n> > Inner\n> > ---\n";
1330 let warnings = check_with(HeadingStyle::Consistent, src);
1331 assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1332 assert!(warnings[0].fix.is_none());
1333 }
1334
1335 #[test]
1336 fn quote_inside_a_list_item_is_checked() {
1337 let src = "# Top\n\n- item\n\n > Quoted\n > ---\n";
1338 let warnings = check_with(HeadingStyle::Consistent, src);
1339 assert_eq!(lines_of(&warnings), vec![5], "{warnings:?}");
1340 assert!(warnings[0].fix.is_none());
1341 let src = "- > Quoted\n > ---\n\n# Top\n";
1342 let warnings = check_with(HeadingStyle::Atx, src);
1343 assert_eq!(lines_of(&warnings), vec![1], "{warnings:?}");
1344 }
1345
1346 #[test]
1347 fn list_item_inside_a_quote_is_checked() {
1348 let src = "# Top\n\n> - # Item heading #\n";
1349 let warnings = check_with(HeadingStyle::Atx, src);
1350 assert_eq!(lines_of(&warnings), vec![3], "{warnings:?}");
1351 assert!(warnings[0].fix.is_none());
1352 }
1353
1354 #[test]
1355 fn fixed_style_flags_quoted_headings_without_a_fix() {
1356 for (style, src) in [
1357 (HeadingStyle::Setext1, "> # H\n"),
1358 (HeadingStyle::Atx, "> H\n> =\n"),
1359 (HeadingStyle::AtxClosed, "> ## H\n"),
1360 (HeadingStyle::Atx, "> > ## H ##\n"),
1361 ] {
1362 let warnings = check_with(style, src);
1363 assert_eq!(warnings.len(), 1, "{style:?} {src:?}: {warnings:?}");
1364 assert!(warnings[0].fix.is_none(), "{style:?} {src:?}");
1365 assert_unchanged_by_fix(style, src);
1366 }
1367 }
1368
1369 #[test]
1370 fn quote_only_document_is_not_skipped() {
1371 let ctx = LintContext::new("> # H\n", MarkdownFlavor::Standard, None);
1372 let rule = MD003HeadingStyle::new(HeadingStyle::Setext1);
1373 assert!(!rule.should_skip(&ctx));
1374 }
1375
1376 #[test]
1377 fn quoted_headings_vote_in_the_consistent_style() {
1378 let src = "# Top\n\n> A\n> -\n\n> B\n> -\n";
1381 let warnings = check_with(HeadingStyle::Consistent, src);
1382 assert_eq!(lines_of(&warnings), vec![1], "{warnings:?}");
1383 assert!(warnings[0].fix.is_some(), "the unquoted heading stays fixable");
1384 }
1385
1386 #[test]
1387 fn quoted_headings_alone_can_set_the_style() {
1388 let src = "> A\n> -\n\n> B\n> -\n";
1389 assert!(check_with(HeadingStyle::Consistent, src).is_empty());
1390 }
1391
1392 #[test]
1393 fn consistent_quoted_and_unquoted_headings_are_clean() {
1394 assert!(check_with(HeadingStyle::Consistent, "# A\n\n> ## B\n\n> > ### C\n").is_empty());
1395 assert!(check_with(HeadingStyle::Consistent, "A\n=\n\n> B\n> -\n").is_empty());
1396 }
1397
1398 #[test]
1399 fn setext_levels_above_two_stay_atx_in_quotes() {
1400 assert!(check_with(HeadingStyle::Setext1, "> ### H\n").is_empty());
1401 assert!(check_with(HeadingStyle::SetextWithAtx, "> ### H\n").is_empty());
1402 }
1403
1404 #[test]
1405 fn code_and_html_look_alikes_are_not_headings() {
1406 assert!(check_with(HeadingStyle::Setext1, "> ```\n> # H\n> ```\n").is_empty());
1407 assert!(check_with(HeadingStyle::Setext1, "> # H\n").is_empty());
1408 assert!(check_with(HeadingStyle::Setext1, "> text # not a heading\n").is_empty());
1409 }
1410 }
1411}