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 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 name(&self) -> &'static str {
237 "MD001"
238 }
239
240 fn description(&self) -> &'static str {
241 "Heading levels should only increment by one level at a time"
242 }
243
244 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
245 let mut warnings = Vec::new();
246
247 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
248 Some(1)
249 } else {
250 None
251 };
252
253 for valid_heading in ctx.valid_headings() {
254 let heading = valid_heading.heading;
255 let line_info = valid_heading.line_info;
256
257 let level = heading.level as usize;
258
259 if ctx
263 .inline_config()
264 .is_rule_disabled(self.name(), valid_heading.line_num)
265 {
266 prev_level = Some(level);
267 continue;
268 }
269
270 let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
271 prev_level = new_prev;
272
273 if fix_info.needs_fix {
274 let line_content = line_info.content(ctx.content);
275 let original_indent = &line_content[..line_info.indent];
276 let replacement =
277 HeadingUtils::convert_heading_style(&heading.raw_text, fix_info.fixed_level as u32, fix_info.style);
278
279 let (start_line, start_col, end_line, end_col) =
280 calculate_heading_range(valid_heading.line_num, line_content);
281
282 warnings.push(LintWarning {
283 rule_name: Some(self.name().to_string()),
284 line: start_line,
285 column: start_col,
286 end_line,
287 end_column: end_col,
288 message: format!(
289 "Expected heading level {}, but found heading level {}",
290 fix_info.fixed_level, level
291 ),
292 severity: Severity::Error,
293 fix: Some(Fix::new(
294 ctx.line_index.line_content_range(valid_heading.line_num),
295 format!("{original_indent}{replacement}"),
296 )),
297 });
298 }
299 }
300
301 Ok(warnings)
302 }
303
304 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
305 if self.should_skip(ctx) {
306 return Ok(ctx.content.to_string());
307 }
308 let warnings = self.check(ctx)?;
309 if warnings.is_empty() {
310 return Ok(ctx.content.to_string());
311 }
312 let warnings =
313 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
314 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
315 }
316
317 fn category(&self) -> RuleCategory {
318 RuleCategory::Heading
319 }
320
321 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
322 if ctx.content.is_empty() || !ctx.likely_has_headings() {
324 return true;
325 }
326 !ctx.has_valid_headings()
328 }
329
330 fn as_any(&self) -> &dyn std::any::Any {
331 self
332 }
333
334 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
335 where
336 Self: Sized,
337 {
338 let rule_config = crate::rule_config_serde::load_rule_config::<MD001Config>(config);
339 Box::new(Self::from_config_struct(
340 rule_config,
341 config.withheld_rule_values.contains("MD001"),
342 ))
343 }
344
345 crate::impl_rule_config_sections!(MD001Config);
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::lint_context::LintContext;
352
353 #[test]
354 fn test_basic_functionality() {
355 let rule = MD001HeadingIncrement::default();
356
357 let content = "# Heading 1\n## Heading 2\n### Heading 3";
359 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
360 let result = rule.check(&ctx).unwrap();
361 assert!(result.is_empty());
362
363 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
366 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
367 let result = rule.check(&ctx).unwrap();
368 assert_eq!(result.len(), 2);
369 assert_eq!(result[0].line, 2);
370 assert_eq!(result[1].line, 3);
371 }
372
373 #[test]
374 fn test_frontmatter_title_counts_as_h1() {
375 let rule = MD001HeadingIncrement::default();
376
377 let content = "---\ntitle: My Document\n---\n\n## First Section";
379 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
380 let result = rule.check(&ctx).unwrap();
381 assert!(
382 result.is_empty(),
383 "H2 after frontmatter title should not trigger warning"
384 );
385
386 let content = "---\ntitle: My Document\n---\n\n### Third Level";
388 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
389 let result = rule.check(&ctx).unwrap();
390 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
391 assert!(result[0].message.contains("Expected heading level 2"));
392 }
393
394 #[test]
395 fn test_frontmatter_without_title() {
396 let rule = MD001HeadingIncrement::default();
397
398 let content = "---\nauthor: John\n---\n\n## First Section";
401 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
402 let result = rule.check(&ctx).unwrap();
403 assert!(
404 result.is_empty(),
405 "First heading after frontmatter without title has no predecessor"
406 );
407 }
408
409 #[test]
410 fn test_frontmatter_title_disabled() {
411 let rule = MD001HeadingIncrement::new(false);
412
413 let content = "---\ntitle: My Document\n---\n\n## First Section";
415 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
416 let result = rule.check(&ctx).unwrap();
417 assert!(
418 result.is_empty(),
419 "With front_matter_title disabled, first heading has no predecessor"
420 );
421 }
422
423 #[test]
424 fn test_frontmatter_title_with_subsequent_headings() {
425 let rule = MD001HeadingIncrement::default();
426
427 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
429 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
430 let result = rule.check(&ctx).unwrap();
431 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
432 }
433
434 #[test]
435 fn test_frontmatter_title_fix() {
436 let rule = MD001HeadingIncrement::default();
437
438 let content = "---\ntitle: My Document\n---\n\n### Third Level";
440 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
441 let fixed = rule.fix(&ctx).unwrap();
442 assert!(
443 fixed.contains("## Third Level"),
444 "H3 should be fixed to H2 when frontmatter has title"
445 );
446 }
447
448 #[test]
449 fn test_toml_frontmatter_title() {
450 let rule = MD001HeadingIncrement::default();
451
452 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
454 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
455 let result = rule.check(&ctx).unwrap();
456 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
457 }
458
459 #[test]
460 fn test_no_frontmatter_no_h1() {
461 let rule = MD001HeadingIncrement::default();
462
463 let content = "## First Section\n\n### Subsection";
465 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
466 let result = rule.check(&ctx).unwrap();
467 assert!(
468 result.is_empty(),
469 "First heading (even if H2) has no predecessor to compare against"
470 );
471 }
472
473 #[test]
474 fn test_fix_preserves_attribute_lists() {
475 let rule = MD001HeadingIncrement::default();
476
477 let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
479 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
480
481 let fixed = rule.fix(&ctx).unwrap();
483 assert!(
484 fixed.contains("## Heading 3 { #custom-id .special }"),
485 "fix() should preserve attribute list, got: {fixed}"
486 );
487
488 let warnings = rule.check(&ctx).unwrap();
490 assert_eq!(warnings.len(), 1);
491 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
492 assert!(
493 fix.replacement.contains("{ #custom-id .special }"),
494 "check() fix should preserve attribute list, got: {}",
495 fix.replacement
496 );
497 }
498
499 #[test]
500 fn test_check_single_skip_with_repeated_level() {
501 let rule = MD001HeadingIncrement::default();
502
503 let content = "# H1\n### H3a\n### H3b";
506 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
507
508 let warnings = rule.check(&ctx).unwrap();
509 assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
510 assert!(warnings[0].message.contains("Expected heading level 2"));
511
512 let fixed = rule.fix(&ctx).unwrap();
514 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
515 let warnings_after = rule.check(&ctx_fixed).unwrap();
516 assert!(
517 warnings_after.is_empty(),
518 "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
519 );
520 }
521
522 #[test]
523 fn test_check_cascading_skip_produces_idempotent_fix() {
524 let rule = MD001HeadingIncrement::default();
525
526 let content = "# Title\n#### Deep\n##### Deeper";
531 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532
533 let warnings = rule.check(&ctx).unwrap();
534 assert_eq!(
535 warnings.len(),
536 2,
537 "Both deep headings should be flagged for idempotent fix"
538 );
539 assert!(warnings[0].message.contains("Expected heading level 2"));
540 assert!(warnings[1].message.contains("Expected heading level 3"));
541
542 let fixed = rule.fix(&ctx).unwrap();
544 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
545 let warnings_after = rule.check(&ctx_fixed).unwrap();
546 assert!(
547 warnings_after.is_empty(),
548 "Fixed content should have no warnings: {fixed:?}"
549 );
550 }
551
552 #[test]
553 fn test_check_level_decrease_resets_tracking() {
554 let rule = MD001HeadingIncrement::default();
555
556 let content = "# Title\n### Sub\n# Another\n### Sub2";
558 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
559
560 let warnings = rule.check(&ctx).unwrap();
561 assert_eq!(
562 warnings.len(),
563 2,
564 "Both H3 headings should be flagged (each follows an H1)"
565 );
566
567 let fixed = rule.fix(&ctx).unwrap();
569 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
570 assert!(
571 rule.check(&ctx_fixed).unwrap().is_empty(),
572 "Fixed content should pass: {fixed:?}"
573 );
574 }
575
576 #[test]
579 fn test_check_and_fix_produce_identical_replacements() {
580 let rule = MD001HeadingIncrement::default();
581
582 let inputs = [
583 "# H1\n### H3\n",
584 "# H1\n#### H4\n##### H5\n",
585 "# H1\n### H3\n# H1b\n### H3b\n",
586 "# H1\n\n### H3 { #custom-id }\n",
587 "---\ntitle: Doc\n---\n\n### Deep\n",
588 ];
589
590 for input in &inputs {
591 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
592 let warnings = rule.check(&ctx).unwrap();
593 let fixed = rule.fix(&ctx).unwrap();
594 let fixed_lines: Vec<&str> = fixed.lines().collect();
595
596 for warning in &warnings {
597 if let Some(ref fix) = warning.fix {
598 let line_idx = warning.line - 1;
600 assert!(
601 line_idx < fixed_lines.len(),
602 "Warning line {} out of range for fixed output (input: {input:?})",
603 warning.line,
604 );
605 let fix_output_line = fixed_lines[line_idx];
606 assert_eq!(
607 fix.replacement, fix_output_line,
608 "check() fix and fix() output diverge at line {} (input: {input:?})",
609 warning.line,
610 );
611 }
612 }
613 }
614 }
615
616 #[test]
619 fn test_setext_headings_mixed_with_atx_cascading() {
620 let rule = MD001HeadingIncrement::default();
621
622 let content = "Setext Title\n============\n\n#### Deep ATX\n";
623 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
624
625 let warnings = rule.check(&ctx).unwrap();
626 assert_eq!(warnings.len(), 1);
627 assert!(warnings[0].message.contains("Expected heading level 2"));
628
629 let fixed = rule.fix(&ctx).unwrap();
630 assert!(
631 fixed.contains("## Deep ATX"),
632 "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
633 );
634
635 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
637 assert!(
638 rule.check(&ctx_fixed).unwrap().is_empty(),
639 "Fixed content should produce no warnings"
640 );
641 }
642
643 #[test]
645 fn test_fix_idempotent_applied_twice() {
646 let rule = MD001HeadingIncrement::default();
647
648 let inputs = [
649 "# H1\n### H3\n#### H4\n",
650 "## H2\n##### H5\n###### H6\n",
651 "# A\n### B\n# C\n### D\n##### E\n",
652 "# H1\nH2\n--\n#### H4\n",
653 "Title\n=====\n",
655 "Title\n=====\n\n#### Deep\n",
656 "Sub\n---\n\n#### Deep\n",
657 "T1\n==\nT2\n--\n#### Deep\n",
658 ];
659
660 for input in &inputs {
661 let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
662 let fixed_once = rule.fix(&ctx1).unwrap();
663
664 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
665 let fixed_twice = rule.fix(&ctx2).unwrap();
666
667 assert_eq!(
668 fixed_once, fixed_twice,
669 "fix() is not idempotent for input: {input:?}\nfirst: {fixed_once:?}\nsecond: {fixed_twice:?}"
670 );
671 }
672 }
673
674 #[test]
677 fn test_setext_fix_no_underline_duplication() {
678 let rule = MD001HeadingIncrement::default();
679
680 let content = "Title\n=====\n";
682 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let fixed = rule.fix(&ctx).unwrap();
684 assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
685
686 let content = "Sub\n---\n";
688 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let fixed = rule.fix(&ctx).unwrap();
690 assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
691
692 let content = "Title\n=====\nSub\n---\n";
694 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let fixed = rule.fix(&ctx).unwrap();
696 assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
697
698 let content = "Title\n=====";
700 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701 let fixed = rule.fix(&ctx).unwrap();
702 assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
703
704 let content = "Sub\n---\n\n#### Deep\n";
706 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
707 let fixed = rule.fix(&ctx).unwrap();
708 assert!(
709 fixed.contains("### Deep"),
710 "H4 after Setext H2 should become H3, got: {fixed}"
711 );
712 assert_eq!(
713 fixed.matches("---").count(),
714 1,
715 "Underline should not be duplicated, got: {fixed}"
716 );
717
718 let content = "Hi\n==========\n";
720 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
721 let fixed = rule.fix(&ctx).unwrap();
722 assert_eq!(
723 fixed, content,
724 "Valid Setext with long underline must be preserved exactly, got: {fixed}"
725 );
726
727 let content = "Long Title Here\n===\n";
729 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730 let fixed = rule.fix(&ctx).unwrap();
731 assert_eq!(
732 fixed, content,
733 "Valid Setext with short underline must be preserved exactly, got: {fixed}"
734 );
735 }
736
737 #[test]
741 fn test_roundtrip_fix_produces_no_warnings() {
742 let rule = MD001HeadingIncrement::default();
743
744 let inputs = [
745 "# H1\n### H3\n",
746 "# H1\n#### H4\n##### H5\n",
747 "# H1\n### H3\n# H1b\n### H3b\n",
748 "# H1\n\n### H3 { #custom-id }\n",
749 "---\ntitle: Doc\n---\n\n### Deep\n",
750 "Title\n=====\n\n#### Deep\n",
751 "Sub\n---\n\n#### Deep\n",
752 "# A\n### B\n# C\n### D\n##### E\n",
753 "# Title\n#### Deep\n##### Deeper\n###### Deepest\n",
754 ];
755
756 for input in &inputs {
757 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
758 let fixed = rule.fix(&ctx).unwrap();
759
760 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
761 let warnings_after = rule.check(&ctx_fixed).unwrap();
762 assert!(
763 warnings_after.is_empty(),
764 "Fix should produce clean output for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
765 );
766
767 let fixed_twice = rule.fix(&ctx_fixed).unwrap();
769 assert_eq!(
770 fixed, fixed_twice,
771 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
772 );
773 }
774 }
775
776 #[test]
779 fn test_inline_disable_preserves_content() {
780 let rule = MD001HeadingIncrement::default();
781
782 let content = "# H1\n\n<!-- rumdl-disable-next-line MD001 -->\n#### H4\n\n##### H5\n";
784 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
785
786 let fixed = rule.fix(&ctx).unwrap();
787 assert!(fixed.contains("#### H4"), "Disabled heading should be preserved");
789 assert!(fixed.contains("##### H5"), "Heading after disabled should be preserved");
790 }
791}