1use crate::HeadingStyle;
2use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
3use crate::rules::front_matter_utils::FrontMatterUtils;
4use crate::rules::heading_utils::HeadingUtils;
5use crate::utils::range_utils::calculate_heading_range;
6use regex::Regex;
7
8#[derive(Debug, Clone)]
78pub struct MD001HeadingIncrement {
79 pub front_matter_title: bool,
81 pub front_matter_title_pattern: Option<Regex>,
83}
84
85impl Default for MD001HeadingIncrement {
86 fn default() -> Self {
87 Self {
88 front_matter_title: true,
89 front_matter_title_pattern: None,
90 }
91 }
92}
93
94struct HeadingFixInfo {
96 fixed_level: usize,
98 style: HeadingStyle,
100 needs_fix: bool,
102}
103
104impl MD001HeadingIncrement {
105 pub fn new(front_matter_title: bool) -> Self {
107 Self {
108 front_matter_title,
109 front_matter_title_pattern: None,
110 }
111 }
112
113 pub fn with_pattern(front_matter_title: bool, pattern: Option<String>) -> Self {
115 let front_matter_title_pattern = pattern.and_then(|p| match Regex::new(&p) {
116 Ok(regex) => Some(regex),
117 Err(e) => {
118 log::warn!("Invalid front_matter_title_pattern regex for MD001: {e}");
119 None
120 }
121 });
122
123 Self {
124 front_matter_title,
125 front_matter_title_pattern,
126 }
127 }
128
129 fn has_front_matter_title(&self, content: &str) -> bool {
131 if !self.front_matter_title {
132 return false;
133 }
134
135 if let Some(ref pattern) = self.front_matter_title_pattern {
137 let front_matter_lines = FrontMatterUtils::extract_front_matter(content);
138 for line in front_matter_lines {
139 if pattern.is_match(line) {
140 return true;
141 }
142 }
143 return false;
144 }
145
146 FrontMatterUtils::has_front_matter_field(content, "title:")
148 }
149
150 fn compute_heading_fix(
155 prev_level: Option<usize>,
156 heading: &crate::lint_context::HeadingInfo,
157 ) -> (HeadingFixInfo, Option<usize>) {
158 let level = heading.level as usize;
159
160 let (fixed_level, needs_fix) = if let Some(prev) = prev_level
161 && level > prev + 1
162 {
163 (prev + 1, true)
164 } else {
165 (level, false)
166 };
167
168 let style = match heading.style {
170 crate::lint_context::HeadingStyle::ATX => HeadingStyle::Atx,
171 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2 => {
172 if fixed_level == 1 {
173 HeadingStyle::Setext1
174 } else {
175 HeadingStyle::Setext2
176 }
177 }
178 };
179
180 let info = HeadingFixInfo {
181 fixed_level,
182 style,
183 needs_fix,
184 };
185 (info, Some(fixed_level))
186 }
187}
188
189impl Rule for MD001HeadingIncrement {
190 fn name(&self) -> &'static str {
191 "MD001"
192 }
193
194 fn description(&self) -> &'static str {
195 "Heading levels should only increment by one level at a time"
196 }
197
198 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
199 let mut warnings = Vec::new();
200
201 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
202 Some(1)
203 } else {
204 None
205 };
206
207 for valid_heading in ctx.valid_headings() {
208 let heading = valid_heading.heading;
209 let line_info = valid_heading.line_info;
210
211 let level = heading.level as usize;
212
213 if ctx
217 .inline_config()
218 .is_rule_disabled(self.name(), valid_heading.line_num)
219 {
220 prev_level = Some(level);
221 continue;
222 }
223
224 let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
225 prev_level = new_prev;
226
227 if fix_info.needs_fix {
228 let line_content = line_info.content(ctx.content);
229 let original_indent = &line_content[..line_info.indent];
230 let replacement =
231 HeadingUtils::convert_heading_style(&heading.raw_text, fix_info.fixed_level as u32, fix_info.style);
232
233 let (start_line, start_col, end_line, end_col) =
234 calculate_heading_range(valid_heading.line_num, line_content);
235
236 warnings.push(LintWarning {
237 rule_name: Some(self.name().to_string()),
238 line: start_line,
239 column: start_col,
240 end_line,
241 end_column: end_col,
242 message: format!(
243 "Expected heading level {}, but found heading level {}",
244 fix_info.fixed_level, level
245 ),
246 severity: Severity::Error,
247 fix: Some(Fix {
248 range: ctx.line_index.line_content_range(valid_heading.line_num),
249 replacement: format!("{original_indent}{replacement}"),
250 }),
251 });
252 }
253 }
254
255 Ok(warnings)
256 }
257
258 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
259 if self.should_skip(ctx) {
260 return Ok(ctx.content.to_string());
261 }
262 let warnings = self.check(ctx)?;
263 if warnings.is_empty() {
264 return Ok(ctx.content.to_string());
265 }
266 let warnings =
267 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
268 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
269 }
270
271 fn category(&self) -> RuleCategory {
272 RuleCategory::Heading
273 }
274
275 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
276 if ctx.content.is_empty() || !ctx.likely_has_headings() {
278 return true;
279 }
280 !ctx.has_valid_headings()
282 }
283
284 fn as_any(&self) -> &dyn std::any::Any {
285 self
286 }
287
288 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
289 where
290 Self: Sized,
291 {
292 let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
294 let fmt = rule_config
295 .values
296 .get("front-matter-title")
297 .or_else(|| rule_config.values.get("front_matter_title"))
298 .and_then(|v| v.as_bool())
299 .unwrap_or(true);
300
301 let pattern = rule_config
302 .values
303 .get("front-matter-title-pattern")
304 .or_else(|| rule_config.values.get("front_matter_title_pattern"))
305 .and_then(|v| v.as_str())
306 .filter(|s: &&str| !s.is_empty())
307 .map(String::from);
308
309 (fmt, pattern)
310 } else {
311 (true, None)
312 };
313
314 Box::new(MD001HeadingIncrement::with_pattern(
315 front_matter_title,
316 front_matter_title_pattern,
317 ))
318 }
319
320 fn default_config_section(&self) -> Option<(String, toml::Value)> {
321 Some((
322 "MD001".to_string(),
323 toml::toml! {
324 front-matter-title = true
325 }
326 .into(),
327 ))
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334 use crate::lint_context::LintContext;
335
336 #[test]
337 fn test_basic_functionality() {
338 let rule = MD001HeadingIncrement::default();
339
340 let content = "# Heading 1\n## Heading 2\n### Heading 3";
342 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
343 let result = rule.check(&ctx).unwrap();
344 assert!(result.is_empty());
345
346 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
349 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
350 let result = rule.check(&ctx).unwrap();
351 assert_eq!(result.len(), 2);
352 assert_eq!(result[0].line, 2);
353 assert_eq!(result[1].line, 3);
354 }
355
356 #[test]
357 fn test_frontmatter_title_counts_as_h1() {
358 let rule = MD001HeadingIncrement::default();
359
360 let content = "---\ntitle: My Document\n---\n\n## First Section";
362 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
363 let result = rule.check(&ctx).unwrap();
364 assert!(
365 result.is_empty(),
366 "H2 after frontmatter title should not trigger warning"
367 );
368
369 let content = "---\ntitle: My Document\n---\n\n### Third Level";
371 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
372 let result = rule.check(&ctx).unwrap();
373 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
374 assert!(result[0].message.contains("Expected heading level 2"));
375 }
376
377 #[test]
378 fn test_frontmatter_without_title() {
379 let rule = MD001HeadingIncrement::default();
380
381 let content = "---\nauthor: John\n---\n\n## First Section";
384 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
385 let result = rule.check(&ctx).unwrap();
386 assert!(
387 result.is_empty(),
388 "First heading after frontmatter without title has no predecessor"
389 );
390 }
391
392 #[test]
393 fn test_frontmatter_title_disabled() {
394 let rule = MD001HeadingIncrement::new(false);
395
396 let content = "---\ntitle: My Document\n---\n\n## First Section";
398 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
399 let result = rule.check(&ctx).unwrap();
400 assert!(
401 result.is_empty(),
402 "With front_matter_title disabled, first heading has no predecessor"
403 );
404 }
405
406 #[test]
407 fn test_frontmatter_title_with_subsequent_headings() {
408 let rule = MD001HeadingIncrement::default();
409
410 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
412 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
413 let result = rule.check(&ctx).unwrap();
414 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
415 }
416
417 #[test]
418 fn test_frontmatter_title_fix() {
419 let rule = MD001HeadingIncrement::default();
420
421 let content = "---\ntitle: My Document\n---\n\n### Third Level";
423 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
424 let fixed = rule.fix(&ctx).unwrap();
425 assert!(
426 fixed.contains("## Third Level"),
427 "H3 should be fixed to H2 when frontmatter has title"
428 );
429 }
430
431 #[test]
432 fn test_toml_frontmatter_title() {
433 let rule = MD001HeadingIncrement::default();
434
435 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
437 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
438 let result = rule.check(&ctx).unwrap();
439 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
440 }
441
442 #[test]
443 fn test_no_frontmatter_no_h1() {
444 let rule = MD001HeadingIncrement::default();
445
446 let content = "## First Section\n\n### Subsection";
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 "First heading (even if H2) has no predecessor to compare against"
453 );
454 }
455
456 #[test]
457 fn test_fix_preserves_attribute_lists() {
458 let rule = MD001HeadingIncrement::default();
459
460 let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
462 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
463
464 let fixed = rule.fix(&ctx).unwrap();
466 assert!(
467 fixed.contains("## Heading 3 { #custom-id .special }"),
468 "fix() should preserve attribute list, got: {fixed}"
469 );
470
471 let warnings = rule.check(&ctx).unwrap();
473 assert_eq!(warnings.len(), 1);
474 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
475 assert!(
476 fix.replacement.contains("{ #custom-id .special }"),
477 "check() fix should preserve attribute list, got: {}",
478 fix.replacement
479 );
480 }
481
482 #[test]
483 fn test_check_single_skip_with_repeated_level() {
484 let rule = MD001HeadingIncrement::default();
485
486 let content = "# H1\n### H3a\n### H3b";
489 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
490
491 let warnings = rule.check(&ctx).unwrap();
492 assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
493 assert!(warnings[0].message.contains("Expected heading level 2"));
494
495 let fixed = rule.fix(&ctx).unwrap();
497 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
498 let warnings_after = rule.check(&ctx_fixed).unwrap();
499 assert!(
500 warnings_after.is_empty(),
501 "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
502 );
503 }
504
505 #[test]
506 fn test_check_cascading_skip_produces_idempotent_fix() {
507 let rule = MD001HeadingIncrement::default();
508
509 let content = "# Title\n#### Deep\n##### Deeper";
514 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
515
516 let warnings = rule.check(&ctx).unwrap();
517 assert_eq!(
518 warnings.len(),
519 2,
520 "Both deep headings should be flagged for idempotent fix"
521 );
522 assert!(warnings[0].message.contains("Expected heading level 2"));
523 assert!(warnings[1].message.contains("Expected heading level 3"));
524
525 let fixed = rule.fix(&ctx).unwrap();
527 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
528 let warnings_after = rule.check(&ctx_fixed).unwrap();
529 assert!(
530 warnings_after.is_empty(),
531 "Fixed content should have no warnings: {fixed:?}"
532 );
533 }
534
535 #[test]
536 fn test_check_level_decrease_resets_tracking() {
537 let rule = MD001HeadingIncrement::default();
538
539 let content = "# Title\n### Sub\n# Another\n### Sub2";
541 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
542
543 let warnings = rule.check(&ctx).unwrap();
544 assert_eq!(
545 warnings.len(),
546 2,
547 "Both H3 headings should be flagged (each follows an H1)"
548 );
549
550 let fixed = rule.fix(&ctx).unwrap();
552 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
553 assert!(
554 rule.check(&ctx_fixed).unwrap().is_empty(),
555 "Fixed content should pass: {fixed:?}"
556 );
557 }
558
559 #[test]
562 fn test_check_and_fix_produce_identical_replacements() {
563 let rule = MD001HeadingIncrement::default();
564
565 let inputs = [
566 "# H1\n### H3\n",
567 "# H1\n#### H4\n##### H5\n",
568 "# H1\n### H3\n# H1b\n### H3b\n",
569 "# H1\n\n### H3 { #custom-id }\n",
570 "---\ntitle: Doc\n---\n\n### Deep\n",
571 ];
572
573 for input in &inputs {
574 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
575 let warnings = rule.check(&ctx).unwrap();
576 let fixed = rule.fix(&ctx).unwrap();
577 let fixed_lines: Vec<&str> = fixed.lines().collect();
578
579 for warning in &warnings {
580 if let Some(ref fix) = warning.fix {
581 let line_idx = warning.line - 1;
583 assert!(
584 line_idx < fixed_lines.len(),
585 "Warning line {} out of range for fixed output (input: {input:?})",
586 warning.line,
587 );
588 let fix_output_line = fixed_lines[line_idx];
589 assert_eq!(
590 fix.replacement, fix_output_line,
591 "check() fix and fix() output diverge at line {} (input: {input:?})",
592 warning.line,
593 );
594 }
595 }
596 }
597 }
598
599 #[test]
602 fn test_setext_headings_mixed_with_atx_cascading() {
603 let rule = MD001HeadingIncrement::default();
604
605 let content = "Setext Title\n============\n\n#### Deep ATX\n";
606 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
607
608 let warnings = rule.check(&ctx).unwrap();
609 assert_eq!(warnings.len(), 1);
610 assert!(warnings[0].message.contains("Expected heading level 2"));
611
612 let fixed = rule.fix(&ctx).unwrap();
613 assert!(
614 fixed.contains("## Deep ATX"),
615 "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
616 );
617
618 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
620 assert!(
621 rule.check(&ctx_fixed).unwrap().is_empty(),
622 "Fixed content should produce no warnings"
623 );
624 }
625
626 #[test]
628 fn test_fix_idempotent_applied_twice() {
629 let rule = MD001HeadingIncrement::default();
630
631 let inputs = [
632 "# H1\n### H3\n#### H4\n",
633 "## H2\n##### H5\n###### H6\n",
634 "# A\n### B\n# C\n### D\n##### E\n",
635 "# H1\nH2\n--\n#### H4\n",
636 "Title\n=====\n",
638 "Title\n=====\n\n#### Deep\n",
639 "Sub\n---\n\n#### Deep\n",
640 "T1\n==\nT2\n--\n#### Deep\n",
641 ];
642
643 for input in &inputs {
644 let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
645 let fixed_once = rule.fix(&ctx1).unwrap();
646
647 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
648 let fixed_twice = rule.fix(&ctx2).unwrap();
649
650 assert_eq!(
651 fixed_once, fixed_twice,
652 "fix() is not idempotent for input: {input:?}\nfirst: {fixed_once:?}\nsecond: {fixed_twice:?}"
653 );
654 }
655 }
656
657 #[test]
660 fn test_setext_fix_no_underline_duplication() {
661 let rule = MD001HeadingIncrement::default();
662
663 let content = "Title\n=====\n";
665 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666 let fixed = rule.fix(&ctx).unwrap();
667 assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
668
669 let content = "Sub\n---\n";
671 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let fixed = rule.fix(&ctx).unwrap();
673 assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
674
675 let content = "Title\n=====\nSub\n---\n";
677 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
678 let fixed = rule.fix(&ctx).unwrap();
679 assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
680
681 let content = "Title\n=====";
683 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
684 let fixed = rule.fix(&ctx).unwrap();
685 assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
686
687 let content = "Sub\n---\n\n#### Deep\n";
689 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690 let fixed = rule.fix(&ctx).unwrap();
691 assert!(
692 fixed.contains("### Deep"),
693 "H4 after Setext H2 should become H3, got: {fixed}"
694 );
695 assert_eq!(
696 fixed.matches("---").count(),
697 1,
698 "Underline should not be duplicated, got: {fixed}"
699 );
700
701 let content = "Hi\n==========\n";
703 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let fixed = rule.fix(&ctx).unwrap();
705 assert_eq!(
706 fixed, content,
707 "Valid Setext with long underline must be preserved exactly, got: {fixed}"
708 );
709
710 let content = "Long Title Here\n===\n";
712 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
713 let fixed = rule.fix(&ctx).unwrap();
714 assert_eq!(
715 fixed, content,
716 "Valid Setext with short underline must be preserved exactly, got: {fixed}"
717 );
718 }
719
720 #[test]
724 fn test_roundtrip_fix_produces_no_warnings() {
725 let rule = MD001HeadingIncrement::default();
726
727 let inputs = [
728 "# H1\n### H3\n",
729 "# H1\n#### H4\n##### H5\n",
730 "# H1\n### H3\n# H1b\n### H3b\n",
731 "# H1\n\n### H3 { #custom-id }\n",
732 "---\ntitle: Doc\n---\n\n### Deep\n",
733 "Title\n=====\n\n#### Deep\n",
734 "Sub\n---\n\n#### Deep\n",
735 "# A\n### B\n# C\n### D\n##### E\n",
736 "# Title\n#### Deep\n##### Deeper\n###### Deepest\n",
737 ];
738
739 for input in &inputs {
740 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
741 let fixed = rule.fix(&ctx).unwrap();
742
743 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
744 let warnings_after = rule.check(&ctx_fixed).unwrap();
745 assert!(
746 warnings_after.is_empty(),
747 "Fix should produce clean output for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
748 );
749
750 let fixed_twice = rule.fix(&ctx_fixed).unwrap();
752 assert_eq!(
753 fixed, fixed_twice,
754 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
755 );
756 }
757 }
758
759 #[test]
762 fn test_inline_disable_preserves_content() {
763 let rule = MD001HeadingIncrement::default();
764
765 let content = "# H1\n\n<!-- rumdl-disable-next-line MD001 -->\n#### H4\n\n##### H5\n";
767 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768
769 let fixed = rule.fix(&ctx).unwrap();
770 assert!(fixed.contains("#### H4"), "Disabled heading should be preserved");
772 assert!(fixed.contains("##### H5"), "Heading after disabled should be preserved");
773 }
774}