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