1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rule_config_serde::RuleConfig;
4use crate::rules::front_matter_utils::FrontMatterUtils;
5use crate::rules::heading_utils::HeadingUtils;
6use crate::utils::range_utils::calculate_heading_range;
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12#[serde(rename_all = "kebab-case")]
13pub(super) struct MD001Config {
14 #[serde(default = "default_front_matter_title", alias = "front_matter_title")]
16 pub front_matter_title: bool,
17
18 #[serde(default, alias = "front_matter_title_pattern")]
22 pub front_matter_title_pattern: Option<String>,
23}
24
25fn default_front_matter_title() -> bool {
26 true
27}
28
29impl Default for MD001Config {
30 fn default() -> Self {
31 Self {
32 front_matter_title: default_front_matter_title(),
33 front_matter_title_pattern: None,
34 }
35 }
36}
37
38impl RuleConfig for MD001Config {
39 const RULE_NAME: &'static str = "MD001";
40}
41
42#[derive(Debug, Clone)]
112pub struct MD001HeadingIncrement {
113 pub front_matter_title: bool,
115 pub front_matter_title_pattern: Option<Regex>,
117}
118
119impl Default for MD001HeadingIncrement {
120 fn default() -> Self {
121 Self {
122 front_matter_title: true,
123 front_matter_title_pattern: None,
124 }
125 }
126}
127
128struct HeadingFixInfo {
130 fixed_level: usize,
132 style: HeadingStyle,
134 needs_fix: bool,
136}
137
138impl MD001HeadingIncrement {
139 pub fn new(front_matter_title: bool) -> Self {
141 Self {
142 front_matter_title,
143 front_matter_title_pattern: None,
144 }
145 }
146
147 pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
149 Self::with_pattern_from(front_matter_title, pattern, false)
150 }
151
152 fn with_pattern_from(front_matter_title: bool, pattern: Option<String>, values_withheld: bool) -> Self {
155 let front_matter_title_pattern = pattern.and_then(|p| {
156 crate::rule_config_serde::compile_config_regex(&p, "MD001", "front-matter-title-pattern", values_withheld)
157 });
158
159 Self {
160 front_matter_title,
161 front_matter_title_pattern,
162 }
163 }
164
165 fn from_config_struct(config: MD001Config, values_withheld: bool) -> Self {
168 Self::with_pattern_from(
169 config.front_matter_title,
170 config.front_matter_title_pattern.filter(|p| !p.is_empty()),
171 values_withheld,
172 )
173 }
174
175 pub(crate) fn has_front_matter_title(&self, content: &str) -> bool {
177 if !self.front_matter_title {
178 return false;
179 }
180
181 if let Some(ref pattern) = self.front_matter_title_pattern {
183 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
184 for line in front_matter_lines {
185 if pattern.is_match(line) {
186 return true;
187 }
188 }
189 return false;
190 }
191
192 FrontMatterUtils::has_front_matter_field(content, "title:")
194 }
195
196 fn compute_heading_fix(
201 prev_level: Option<usize>,
202 heading: &crate::lint_context::HeadingInfo,
203 ) -> (HeadingFixInfo, Option<usize>) {
204 let level = heading.level as usize;
205
206 let (fixed_level, needs_fix) = if let Some(prev) = prev_level
207 && level > prev + 1
208 {
209 (prev + 1, true)
210 } else {
211 (level, false)
212 };
213
214 let style = match heading.style {
216 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
217 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
218 if fixed_level == 1 {
219 HeadingStyle::Setext1
220 } else {
221 HeadingStyle::Setext2
222 }
223 }
224 };
225
226 let info = HeadingFixInfo {
227 fixed_level,
228 style,
229 needs_fix,
230 };
231 (info, Some(fixed_level))
232 }
233}
234
235impl Rule for MD001HeadingIncrement {
236 fn native_format_policy(&self) -> Option<crate::format_proof::NativeFormatPolicy> {
237 (self.front_matter_title && self.front_matter_title_pattern.is_none())
238 .then_some(crate::format_proof::NativeFormatPolicy::for_rule::<Self>(1))
239 }
240
241 fn name(&self) -> &'static str {
242 "MD001"
243 }
244
245 fn description(&self) -> &'static str {
246 "Heading levels should only increment by one level at a time"
247 }
248
249 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
250 let mut warnings = Vec::new();
251
252 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
253 Some(1)
254 } else {
255 None
256 };
257
258 for valid_heading in ctx.headings() {
259 let heading = valid_heading.heading;
260 let line_info = valid_heading.line_info;
261 let marker_idx = valid_heading.line_num - 1
262 + usize::from(matches!(
263 heading.style,
264 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
265 ));
266 if let Some(marker_line) = ctx.lines.get(marker_idx) {
267 let marker_offset = marker_line.byte_offset + marker_line.indent;
268 if ctx.is_inside_template_code(marker_offset) || ctx.is_inside_mdx_code(marker_offset) {
269 continue;
270 }
271 }
272
273 let level = heading.level as usize;
274
275 if ctx
279 .inline_config()
280 .is_rule_disabled(self.name(), valid_heading.line_num)
281 {
282 prev_level = Some(level);
283 continue;
284 }
285
286 let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
287 prev_level = new_prev;
288
289 if fix_info.needs_fix {
290 let line_content = line_info.content(ctx.content);
291 let fix = if heading.style == crate::lint_context::HeadingStyle::ATX {
292 let marker_start = line_info.byte_offset + heading.marker_column;
294 Some(Fix::new(
295 marker_start..marker_start + heading.marker.len(),
296 "#".repeat(fix_info.fixed_level),
297 ))
298 } else {
299 let original_indent = &line_content[..line_info.indent];
300 let replacement = HeadingUtils::convert_heading_style(
301 &heading.raw_text,
302 fix_info.fixed_level as u32,
303 fix_info.style,
304 );
305 Some(Fix::new(
306 ctx.line_content_byte_range(valid_heading.line_num),
307 format!("{original_indent}{replacement}"),
308 ))
309 };
310
311 let (start_line, start_col, end_line, end_col) =
312 calculate_heading_range(valid_heading.first_line_num(), valid_heading.line_num, line_content);
313
314 warnings.push(LintWarning {
315 rule_name: Some(self.name().to_string()),
316 line: start_line,
317 column: start_col,
318 end_line,
319 end_column: end_col,
320 message: format!(
321 "Expected heading level {}, but found heading level {}",
322 fix_info.fixed_level, level
323 ),
324 severity: Severity::Error,
325 fix,
326 });
327 }
328 }
329
330 Ok(warnings)
331 }
332
333 fn fixes_from_diagnostics(&self) -> bool {
334 true
335 }
336
337 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
338 if self.should_skip(ctx) {
339 return Ok(ctx.content.to_string());
340 }
341 let warnings = self.check(ctx)?;
342 if warnings.is_empty() {
343 return Ok(ctx.content.to_string());
344 }
345 let warnings =
346 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
347 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
348 }
349
350 fn category(&self) -> RuleCategory {
351 RuleCategory::Heading
352 }
353
354 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
355 if ctx.content.is_empty() || !ctx.likely_has_headings() {
357 return true;
358 }
359 ctx.headings().next().is_none()
361 }
362
363 fn as_any(&self) -> &dyn std::any::Any {
364 self
365 }
366
367 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
368 where
369 Self: Sized,
370 {
371 let rule_config = crate::rule_config_serde::load_rule_config::<MD001Config>(config);
372 Box::new(Self::from_config_struct(
373 rule_config,
374 config.withheld_rule_values.contains("MD001"),
375 ))
376 }
377
378 crate::impl_rule_config_sections!(MD001Config);
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::lint_context::LintContext;
385
386 #[test]
387 fn test_basic_functionality() {
388 let rule = MD001HeadingIncrement::default();
389
390 let content = "# Heading 1\n## Heading 2\n### Heading 3";
392 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
393 let result = rule.check(&ctx).unwrap();
394 assert!(result.is_empty());
395
396 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
399 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
400 let result = rule.check(&ctx).unwrap();
401 assert_eq!(result.len(), 2);
402 assert_eq!(result[0].line, 2);
403 assert_eq!(result[1].line, 3);
404 }
405
406 #[test]
407 fn test_frontmatter_title_counts_as_h1() {
408 let rule = MD001HeadingIncrement::default();
409
410 let content = "---\ntitle: My Document\n---\n\n## First Section";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert!(
415 result.is_empty(),
416 "H2 after frontmatter title should not trigger warning"
417 );
418
419 let content = "---\ntitle: My Document\n---\n\n### Third Level";
421 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
422 let result = rule.check(&ctx).unwrap();
423 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
424 assert!(result[0].message.contains("Expected heading level 2"));
425 }
426
427 #[test]
428 fn test_frontmatter_without_title() {
429 let rule = MD001HeadingIncrement::default();
430
431 let content = "---\nauthor: John\n---\n\n## First Section";
434 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
435 let result = rule.check(&ctx).unwrap();
436 assert!(
437 result.is_empty(),
438 "First heading after frontmatter without title has no predecessor"
439 );
440 }
441
442 #[test]
443 fn test_frontmatter_title_disabled() {
444 let rule = MD001HeadingIncrement::new(false);
445
446 let content = "---\ntitle: My Document\n---\n\n## First Section";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert!(
451 result.is_empty(),
452 "With front_matter_title disabled, first heading has no predecessor"
453 );
454 }
455
456 #[test]
457 fn test_frontmatter_title_with_subsequent_headings() {
458 let rule = MD001HeadingIncrement::default();
459
460 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463 let result = rule.check(&ctx).unwrap();
464 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
465 }
466
467 #[test]
468 fn test_frontmatter_title_fix() {
469 let rule = MD001HeadingIncrement::default();
470
471 let content = "---\ntitle: My Document\n---\n\n### Third Level";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let fixed = rule.fix(&ctx).unwrap();
475 assert!(
476 fixed.contains("## Third Level"),
477 "H3 should be fixed to H2 when frontmatter has title"
478 );
479 }
480
481 #[test]
482 fn test_toml_frontmatter_title() {
483 let rule = MD001HeadingIncrement::default();
484
485 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
487 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
488 let result = rule.check(&ctx).unwrap();
489 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
490 }
491
492 #[test]
493 fn test_no_frontmatter_no_h1() {
494 let rule = MD001HeadingIncrement::default();
495
496 let content = "## First Section\n\n### Subsection";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499 let result = rule.check(&ctx).unwrap();
500 assert!(
501 result.is_empty(),
502 "First heading (even if H2) has no predecessor to compare against"
503 );
504 }
505
506 #[test]
507 fn test_fix_preserves_attribute_lists() {
508 let rule = MD001HeadingIncrement::default();
509
510 let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
512 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
513
514 let fixed = rule.fix(&ctx).unwrap();
516 assert!(
517 fixed.contains("## Heading 3 { #custom-id .special }"),
518 "fix() should preserve attribute list, got: {fixed}"
519 );
520
521 let warnings = rule.check(&ctx).unwrap();
523 assert_eq!(warnings.len(), 1);
524 assert!(warnings[0].fix.is_some(), "Should have a fix");
525 let applied = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).unwrap();
526 assert_eq!(applied, fixed);
527 assert!(applied.contains("{ #custom-id .special }"));
528 }
529
530 #[test]
531 fn test_check_single_skip_with_repeated_level() {
532 let rule = MD001HeadingIncrement::default();
533
534 let content = "# H1\n### H3a\n### H3b";
537 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538
539 let warnings = rule.check(&ctx).unwrap();
540 assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
541 assert!(warnings[0].message.contains("Expected heading level 2"));
542
543 let fixed = rule.fix(&ctx).unwrap();
545 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
546 let warnings_after = rule.check(&ctx_fixed).unwrap();
547 assert!(
548 warnings_after.is_empty(),
549 "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
550 );
551 }
552
553 #[test]
554 fn test_check_cascading_skip_produces_idempotent_fix() {
555 let rule = MD001HeadingIncrement::default();
556
557 let content = "# Title\n#### Deep\n##### Deeper";
562 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
563
564 let warnings = rule.check(&ctx).unwrap();
565 assert_eq!(
566 warnings.len(),
567 2,
568 "Both deep headings should be flagged for idempotent fix"
569 );
570 assert!(warnings[0].message.contains("Expected heading level 2"));
571 assert!(warnings[1].message.contains("Expected heading level 3"));
572
573 let fixed = rule.fix(&ctx).unwrap();
575 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
576 let warnings_after = rule.check(&ctx_fixed).unwrap();
577 assert!(
578 warnings_after.is_empty(),
579 "Fixed content should have no warnings: {fixed:?}"
580 );
581 }
582
583 #[test]
584 fn test_check_level_decrease_resets_tracking() {
585 let rule = MD001HeadingIncrement::default();
586
587 let content = "# Title\n### Sub\n# Another\n### Sub2";
589 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590
591 let warnings = rule.check(&ctx).unwrap();
592 assert_eq!(
593 warnings.len(),
594 2,
595 "Both H3 headings should be flagged (each follows an H1)"
596 );
597
598 let fixed = rule.fix(&ctx).unwrap();
600 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
601 assert!(
602 rule.check(&ctx_fixed).unwrap().is_empty(),
603 "Fixed content should pass: {fixed:?}"
604 );
605 }
606
607 #[test]
609 fn test_check_and_fix_produce_identical_replacements() {
610 let rule = MD001HeadingIncrement::default();
611
612 let inputs = [
613 "# H1\n### H3\n",
614 "# H1\n#### H4\n##### H5\n",
615 "# H1\n### H3\n# H1b\n### H3b\n",
616 "# H1\n\n### H3 { #custom-id }\n",
617 "---\ntitle: Doc\n---\n\n### Deep\n",
618 ];
619
620 for input in &inputs {
621 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
622 let warnings = rule.check(&ctx).unwrap();
623 let fixed = rule.fix(&ctx).unwrap();
624 let applied = crate::utils::fix_utils::apply_warning_fixes(input, &warnings).unwrap();
625 assert_eq!(
626 applied, fixed,
627 "check() fixes and fix() output diverge (input: {input:?})"
628 );
629 }
630 }
631
632 #[test]
635 fn test_setext_headings_mixed_with_atx_cascading() {
636 let rule = MD001HeadingIncrement::default();
637
638 let content = "Setext Title\n============\n\n#### Deep ATX\n";
639 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
640
641 let warnings = rule.check(&ctx).unwrap();
642 assert_eq!(warnings.len(), 1);
643 assert!(warnings[0].message.contains("Expected heading level 2"));
644
645 let fixed = rule.fix(&ctx).unwrap();
646 assert!(
647 fixed.contains("## Deep ATX"),
648 "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
649 );
650
651 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
653 assert!(
654 rule.check(&ctx_fixed).unwrap().is_empty(),
655 "Fixed content should produce no warnings"
656 );
657 }
658
659 #[test]
661 fn test_fix_idempotent_applied_twice() {
662 let rule = MD001HeadingIncrement::default();
663
664 let inputs = [
665 "# H1\n### H3\n#### H4\n",
666 "## H2\n##### H5\n###### H6\n",
667 "# A\n### B\n# C\n### D\n##### E\n",
668 "# H1\nH2\n--\n#### H4\n",
669 "Title\n=====\n",
671 "Title\n=====\n\n#### Deep\n",
672 "Sub\n---\n\n#### Deep\n",
673 "T1\n==\nT2\n--\n#### Deep\n",
674 ];
675
676 for input in &inputs {
677 let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
678 let fixed_once = rule.fix(&ctx1).unwrap();
679
680 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
681 let fixed_twice = rule.fix(&ctx2).unwrap();
682
683 assert_eq!(
684 fixed_once, fixed_twice,
685 "fix() is not idempotent for input: {input:?}\nfirst: {fixed_once:?}\nsecond: {fixed_twice:?}"
686 );
687 }
688 }
689
690 #[test]
693 fn test_setext_fix_no_underline_duplication() {
694 let rule = MD001HeadingIncrement::default();
695
696 let content = "Title\n=====\n";
698 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
699 let fixed = rule.fix(&ctx).unwrap();
700 assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
701
702 let content = "Sub\n---\n";
704 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
705 let fixed = rule.fix(&ctx).unwrap();
706 assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
707
708 let content = "Title\n=====\nSub\n---\n";
710 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
711 let fixed = rule.fix(&ctx).unwrap();
712 assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
713
714 let content = "Title\n=====";
716 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717 let fixed = rule.fix(&ctx).unwrap();
718 assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
719
720 let content = "Sub\n---\n\n#### Deep\n";
722 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723 let fixed = rule.fix(&ctx).unwrap();
724 assert!(
725 fixed.contains("### Deep"),
726 "H4 after Setext H2 should become H3, got: {fixed}"
727 );
728 assert_eq!(
729 fixed.matches("---").count(),
730 1,
731 "Underline should not be duplicated, got: {fixed}"
732 );
733
734 let content = "Hi\n==========\n";
736 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
737 let fixed = rule.fix(&ctx).unwrap();
738 assert_eq!(
739 fixed, content,
740 "Valid Setext with long underline must be preserved exactly, got: {fixed}"
741 );
742
743 let content = "Long Title Here\n===\n";
745 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
746 let fixed = rule.fix(&ctx).unwrap();
747 assert_eq!(
748 fixed, content,
749 "Valid Setext with short underline must be preserved exactly, got: {fixed}"
750 );
751 }
752
753 #[test]
757 fn test_roundtrip_fix_produces_no_warnings() {
758 let rule = MD001HeadingIncrement::default();
759
760 let inputs = [
761 "# H1\n### H3\n",
762 "# H1\n#### H4\n##### H5\n",
763 "# H1\n### H3\n# H1b\n### H3b\n",
764 "# H1\n\n### H3 { #custom-id }\n",
765 "---\ntitle: Doc\n---\n\n### Deep\n",
766 "Title\n=====\n\n#### Deep\n",
767 "Sub\n---\n\n#### Deep\n",
768 "# A\n### B\n# C\n### D\n##### E\n",
769 "# Title\n#### Deep\n##### Deeper\n###### Deepest\n",
770 ];
771
772 for input in &inputs {
773 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
774 let fixed = rule.fix(&ctx).unwrap();
775
776 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
777 let warnings_after = rule.check(&ctx_fixed).unwrap();
778 assert!(
779 warnings_after.is_empty(),
780 "Fix should produce clean output for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
781 );
782
783 let fixed_twice = rule.fix(&ctx_fixed).unwrap();
785 assert_eq!(
786 fixed, fixed_twice,
787 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
788 );
789 }
790 }
791
792 #[test]
795 fn test_inline_disable_preserves_content() {
796 let rule = MD001HeadingIncrement::default();
797
798 let content = "# H1\n\n<!-- rumdl-disable-next-line MD001 -->\n#### H4\n\n##### H5\n";
800 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
801
802 let fixed = rule.fix(&ctx).unwrap();
803 assert!(fixed.contains("#### H4"), "Disabled heading should be preserved");
805 assert!(fixed.contains("##### H5"), "Heading after disabled should be preserved");
806 }
807 #[test]
808 fn test_literal_headings_do_not_hide_visible_level_jumps() {
809 use crate::config::MarkdownFlavor;
810 let rule = MD001HeadingIncrement::default();
811 for literal in ["### Fake", "## Fake", "\nFake\n---\n"] {
812 for (flavor, code) in [
813 (
814 MarkdownFlavor::Standard,
815 format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}"),
816 ),
817 (
818 MarkdownFlavor::Hugo,
819 format!("{{{{< note title=`first\n{literal}\nlast` >}}}}"),
820 ),
821 ] {
822 for ending in ["\n", "\r\n"] {
823 let source = format!("# Visible\n\n{code}\n\n### Next\n").replace('\n', ending);
824 let expected = source.replace("### Next", "## Next");
825 let ctx = LintContext::new(&source, flavor, None);
826 let warnings = rule.check(&ctx).unwrap();
827 assert_eq!(warnings.len(), 1, "{source}");
828 assert_eq!(warnings[0].line, source.lines().count());
829 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
830 }
831 }
832 }
833 }
834
835 #[test]
836 fn test_literal_headings_do_not_initialize_or_reset_levels() {
837 let rule = MD001HeadingIncrement::default();
838 for literal in ["# Fake", "\nFake\n===\n"] {
839 let code = format!("{{% set unused=\"first\n{literal}\nlast\" %}}{{{{ unused|length }}}}");
840 for (prefix, next, target) in [
841 ("", "### Next", "### Next"),
842 ("## Visible\n\n", "#### Next", "### Next"),
843 ] {
844 let source = format!("{prefix}{code}\n\n{next}\n");
845 let expected = format!("{prefix}{code}\n\n{target}\n");
846 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
847 assert_eq!(rule.check(&ctx).unwrap().len(), usize::from(next != target), "{source}");
848 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
849 }
850 }
851 }
852
853 #[test]
854 fn test_dynamic_and_mdx_body_level_jumps_remain_fixable() {
855 let rule = MD001HeadingIncrement::default();
856 for (flavor, source) in [
857 (crate::config::MarkdownFlavor::Standard, "# {{ title }}\n\n### Next\n"),
858 (
859 crate::config::MarkdownFlavor::MDX,
860 "<div>\n\n# Visible\n\n### Next\n\n</div>\n",
861 ),
862 ] {
863 let ctx = LintContext::new(source, flavor, None);
864 assert_eq!(rule.check(&ctx).unwrap().len(), 1);
865 assert_eq!(rule.fix(&ctx).unwrap(), source.replace("### Next", "## Next"));
866 }
867 }
868 #[test]
869 fn test_atx_level_fixes_preserve_literal_titles() {
870 let rule = MD001HeadingIncrement::default();
871 for tail in [" ", " ", "\t", " ###"] {
872 for (flavor, code) in [
873 (
874 crate::config::MarkdownFlavor::Standard,
875 format!("{{% set unused=\"préface{tail}\nlast\" %}}{{{{ unused|length }}}}"),
876 ),
877 (
878 crate::config::MarkdownFlavor::Hugo,
879 format!("{{{{< note title=`préface{tail}\nlast` >}}}}"),
880 ),
881 ] {
882 for ending in ["\n", "\r\n"] {
883 let source = format!("# Visible 🐇\n\n ### {code}\n").replace('\n', ending);
884 let expected = source.replacen(" ### ", " ## ", 1);
885 let ctx = LintContext::new(&source, flavor, None);
886 let warnings = rule.check(&ctx).unwrap();
887 assert_eq!(warnings.len(), 1);
888 let fix = warnings[0].fix.as_ref().unwrap();
889 let start = source.find("### ").unwrap();
890 assert_eq!(fix.range, start..start + 3);
891 assert_eq!(fix.replacement, "##");
892 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
893 }
894 }
895 }
896 }
897
898 #[test]
899 fn test_atx_level_fixes_preserve_title_formatting() {
900 let rule = MD001HeadingIncrement::default();
901 for line in [
902 "### Title ###",
903 "###\tTitle\t###",
904 " ### Title ### ",
905 "### Title ",
906 "### <span title=\"value \">Text</span> ###",
907 "### `value ` ###",
908 ] {
909 let source = format!("# Visible\n\n{line}\n");
910 let expected = source.replacen("###", "##", 1);
911 let ctx = LintContext::new(&source, crate::config::MarkdownFlavor::Standard, None);
912 assert_eq!(rule.fix(&ctx).unwrap(), expected, "{source}");
913 }
914 let source = "# Visible\n\n- Item\n\n ### Title ###\n";
915 let ctx = LintContext::new(source, crate::config::MarkdownFlavor::Standard, None);
916 assert_eq!(rule.fix(&ctx).unwrap(), source.replacen("###", "##", 1));
917 }
918}