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 let level = heading.level as usize;
211
212 let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
213 prev_level = new_prev;
214
215 if fix_info.needs_fix {
216 let line_content = line_info.content(ctx.content);
217 let original_indent = &line_content[..line_info.indent];
218 let replacement =
219 HeadingUtils::convert_heading_style(&heading.raw_text, fix_info.fixed_level as u32, fix_info.style);
220
221 let (start_line, start_col, end_line, end_col) =
222 calculate_heading_range(valid_heading.line_num, line_content);
223
224 warnings.push(LintWarning {
225 rule_name: Some(self.name().to_string()),
226 line: start_line,
227 column: start_col,
228 end_line,
229 end_column: end_col,
230 message: format!(
231 "Expected heading level {}, but found heading level {}",
232 fix_info.fixed_level, level
233 ),
234 severity: Severity::Error,
235 fix: Some(Fix {
236 range: ctx.line_index.line_content_range(valid_heading.line_num),
237 replacement: format!("{original_indent}{replacement}"),
238 }),
239 });
240 }
241 }
242
243 Ok(warnings)
244 }
245
246 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
247 let mut fixed_lines = Vec::new();
248
249 let mut prev_level: Option<usize> = if self.has_front_matter_title(ctx.content) {
250 Some(1)
251 } else {
252 None
253 };
254
255 let mut skip_next = false;
256 for line_info in ctx.lines.iter() {
257 if skip_next {
258 skip_next = false;
259 continue;
260 }
261
262 if let Some(heading) = line_info.heading.as_deref() {
263 if !heading.is_valid {
264 fixed_lines.push(line_info.content(ctx.content).to_string());
265 continue;
266 }
267
268 let is_setext = matches!(
269 heading.style,
270 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
271 );
272
273 let (fix_info, new_prev) = Self::compute_heading_fix(prev_level, heading);
274 prev_level = new_prev;
275
276 if fix_info.needs_fix {
277 let replacement = HeadingUtils::convert_heading_style(
278 &heading.raw_text,
279 fix_info.fixed_level as u32,
280 fix_info.style,
281 );
282 let line = line_info.content(ctx.content);
283 let original_indent = &line[..line_info.indent];
284 fixed_lines.push(format!("{original_indent}{replacement}"));
285
286 if is_setext {
289 skip_next = true;
290 }
291 } else {
292 fixed_lines.push(line_info.content(ctx.content).to_string());
294 }
295 } else {
296 fixed_lines.push(line_info.content(ctx.content).to_string());
297 }
298 }
299
300 let mut result = fixed_lines.join("\n");
301 if ctx.content.ends_with('\n') && !result.ends_with('\n') {
302 result.push('\n');
303 }
304 Ok(result)
305 }
306
307 fn category(&self) -> RuleCategory {
308 RuleCategory::Heading
309 }
310
311 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
312 if ctx.content.is_empty() || !ctx.likely_has_headings() {
314 return true;
315 }
316 !ctx.has_valid_headings()
318 }
319
320 fn as_any(&self) -> &dyn std::any::Any {
321 self
322 }
323
324 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
325 where
326 Self: Sized,
327 {
328 let (front_matter_title, front_matter_title_pattern) = if let Some(rule_config) = config.rules.get("MD001") {
330 let fmt = rule_config
331 .values
332 .get("front-matter-title")
333 .or_else(|| rule_config.values.get("front_matter_title"))
334 .and_then(|v| v.as_bool())
335 .unwrap_or(true);
336
337 let pattern = rule_config
338 .values
339 .get("front-matter-title-pattern")
340 .or_else(|| rule_config.values.get("front_matter_title_pattern"))
341 .and_then(|v| v.as_str())
342 .filter(|s: &&str| !s.is_empty())
343 .map(String::from);
344
345 (fmt, pattern)
346 } else {
347 (true, None)
348 };
349
350 Box::new(MD001HeadingIncrement::with_pattern(
351 front_matter_title,
352 front_matter_title_pattern,
353 ))
354 }
355
356 fn default_config_section(&self) -> Option<(String, toml::Value)> {
357 Some((
358 "MD001".to_string(),
359 toml::toml! {
360 front-matter-title = true
361 }
362 .into(),
363 ))
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::lint_context::LintContext;
371
372 #[test]
373 fn test_basic_functionality() {
374 let rule = MD001HeadingIncrement::default();
375
376 let content = "# Heading 1\n## Heading 2\n### Heading 3";
378 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
379 let result = rule.check(&ctx).unwrap();
380 assert!(result.is_empty());
381
382 let content = "# Heading 1\n### Heading 3\n#### Heading 4";
385 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
386 let result = rule.check(&ctx).unwrap();
387 assert_eq!(result.len(), 2);
388 assert_eq!(result[0].line, 2);
389 assert_eq!(result[1].line, 3);
390 }
391
392 #[test]
393 fn test_frontmatter_title_counts_as_h1() {
394 let rule = MD001HeadingIncrement::default();
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 "H2 after frontmatter title should not trigger warning"
403 );
404
405 let content = "---\ntitle: My Document\n---\n\n### Third Level";
407 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
408 let result = rule.check(&ctx).unwrap();
409 assert_eq!(result.len(), 1, "H3 after frontmatter title should warn");
410 assert!(result[0].message.contains("Expected heading level 2"));
411 }
412
413 #[test]
414 fn test_frontmatter_without_title() {
415 let rule = MD001HeadingIncrement::default();
416
417 let content = "---\nauthor: John\n---\n\n## First Section";
420 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
421 let result = rule.check(&ctx).unwrap();
422 assert!(
423 result.is_empty(),
424 "First heading after frontmatter without title has no predecessor"
425 );
426 }
427
428 #[test]
429 fn test_frontmatter_title_disabled() {
430 let rule = MD001HeadingIncrement::new(false);
431
432 let content = "---\ntitle: My Document\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 "With front_matter_title disabled, first heading has no predecessor"
439 );
440 }
441
442 #[test]
443 fn test_frontmatter_title_with_subsequent_headings() {
444 let rule = MD001HeadingIncrement::default();
445
446 let content = "---\ntitle: My Document\n---\n\n## Introduction\n\n### Details\n\n## Conclusion";
448 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
449 let result = rule.check(&ctx).unwrap();
450 assert!(result.is_empty(), "Valid heading progression after frontmatter title");
451 }
452
453 #[test]
454 fn test_frontmatter_title_fix() {
455 let rule = MD001HeadingIncrement::default();
456
457 let content = "---\ntitle: My Document\n---\n\n### Third Level";
459 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
460 let fixed = rule.fix(&ctx).unwrap();
461 assert!(
462 fixed.contains("## Third Level"),
463 "H3 should be fixed to H2 when frontmatter has title"
464 );
465 }
466
467 #[test]
468 fn test_toml_frontmatter_title() {
469 let rule = MD001HeadingIncrement::default();
470
471 let content = "+++\ntitle = \"My Document\"\n+++\n\n## First Section";
473 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
474 let result = rule.check(&ctx).unwrap();
475 assert!(result.is_empty(), "TOML frontmatter title should count as H1");
476 }
477
478 #[test]
479 fn test_no_frontmatter_no_h1() {
480 let rule = MD001HeadingIncrement::default();
481
482 let content = "## First Section\n\n### Subsection";
484 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
485 let result = rule.check(&ctx).unwrap();
486 assert!(
487 result.is_empty(),
488 "First heading (even if H2) has no predecessor to compare against"
489 );
490 }
491
492 #[test]
493 fn test_fix_preserves_attribute_lists() {
494 let rule = MD001HeadingIncrement::default();
495
496 let content = "# Heading 1\n\n### Heading 3 { #custom-id .special }";
498 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
499
500 let fixed = rule.fix(&ctx).unwrap();
502 assert!(
503 fixed.contains("## Heading 3 { #custom-id .special }"),
504 "fix() should preserve attribute list, got: {fixed}"
505 );
506
507 let warnings = rule.check(&ctx).unwrap();
509 assert_eq!(warnings.len(), 1);
510 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
511 assert!(
512 fix.replacement.contains("{ #custom-id .special }"),
513 "check() fix should preserve attribute list, got: {}",
514 fix.replacement
515 );
516 }
517
518 #[test]
519 fn test_check_single_skip_with_repeated_level() {
520 let rule = MD001HeadingIncrement::default();
521
522 let content = "# H1\n### H3a\n### H3b";
525 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
526
527 let warnings = rule.check(&ctx).unwrap();
528 assert_eq!(warnings.len(), 1, "Only first H3 should be flagged: got {warnings:?}");
529 assert!(warnings[0].message.contains("Expected heading level 2"));
530
531 let fixed = rule.fix(&ctx).unwrap();
533 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
534 let warnings_after = rule.check(&ctx_fixed).unwrap();
535 assert!(
536 warnings_after.is_empty(),
537 "After fix, no warnings should remain: {fixed:?}, warnings: {warnings_after:?}"
538 );
539 }
540
541 #[test]
542 fn test_check_cascading_skip_produces_idempotent_fix() {
543 let rule = MD001HeadingIncrement::default();
544
545 let content = "# Title\n#### Deep\n##### Deeper";
550 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
551
552 let warnings = rule.check(&ctx).unwrap();
553 assert_eq!(
554 warnings.len(),
555 2,
556 "Both deep headings should be flagged for idempotent fix"
557 );
558 assert!(warnings[0].message.contains("Expected heading level 2"));
559 assert!(warnings[1].message.contains("Expected heading level 3"));
560
561 let fixed = rule.fix(&ctx).unwrap();
563 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
564 let warnings_after = rule.check(&ctx_fixed).unwrap();
565 assert!(
566 warnings_after.is_empty(),
567 "Fixed content should have no warnings: {fixed:?}"
568 );
569 }
570
571 #[test]
572 fn test_check_level_decrease_resets_tracking() {
573 let rule = MD001HeadingIncrement::default();
574
575 let content = "# Title\n### Sub\n# Another\n### Sub2";
577 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
578
579 let warnings = rule.check(&ctx).unwrap();
580 assert_eq!(
581 warnings.len(),
582 2,
583 "Both H3 headings should be flagged (each follows an H1)"
584 );
585
586 let fixed = rule.fix(&ctx).unwrap();
588 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
589 assert!(
590 rule.check(&ctx_fixed).unwrap().is_empty(),
591 "Fixed content should pass: {fixed:?}"
592 );
593 }
594
595 #[test]
598 fn test_check_and_fix_produce_identical_replacements() {
599 let rule = MD001HeadingIncrement::default();
600
601 let inputs = [
602 "# H1\n### H3\n",
603 "# H1\n#### H4\n##### H5\n",
604 "# H1\n### H3\n# H1b\n### H3b\n",
605 "# H1\n\n### H3 { #custom-id }\n",
606 "---\ntitle: Doc\n---\n\n### Deep\n",
607 ];
608
609 for input in &inputs {
610 let ctx = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
611 let warnings = rule.check(&ctx).unwrap();
612 let fixed = rule.fix(&ctx).unwrap();
613 let fixed_lines: Vec<&str> = fixed.lines().collect();
614
615 for warning in &warnings {
616 if let Some(ref fix) = warning.fix {
617 let line_idx = warning.line - 1;
619 assert!(
620 line_idx < fixed_lines.len(),
621 "Warning line {} out of range for fixed output (input: {input:?})",
622 warning.line,
623 );
624 let fix_output_line = fixed_lines[line_idx];
625 assert_eq!(
626 fix.replacement, fix_output_line,
627 "check() fix and fix() output diverge at line {} (input: {input:?})",
628 warning.line,
629 );
630 }
631 }
632 }
633 }
634
635 #[test]
638 fn test_setext_headings_mixed_with_atx_cascading() {
639 let rule = MD001HeadingIncrement::default();
640
641 let content = "Setext Title\n============\n\n#### Deep ATX\n";
642 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643
644 let warnings = rule.check(&ctx).unwrap();
645 assert_eq!(warnings.len(), 1);
646 assert!(warnings[0].message.contains("Expected heading level 2"));
647
648 let fixed = rule.fix(&ctx).unwrap();
649 assert!(
650 fixed.contains("## Deep ATX"),
651 "H4 after Setext H1 should be fixed to ATX H2, got: {fixed}"
652 );
653
654 let ctx_fixed = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
656 assert!(
657 rule.check(&ctx_fixed).unwrap().is_empty(),
658 "Fixed content should produce no warnings"
659 );
660 }
661
662 #[test]
664 fn test_fix_idempotent_applied_twice() {
665 let rule = MD001HeadingIncrement::default();
666
667 let inputs = [
668 "# H1\n### H3\n#### H4\n",
669 "## H2\n##### H5\n###### H6\n",
670 "# A\n### B\n# C\n### D\n##### E\n",
671 "# H1\nH2\n--\n#### H4\n",
672 "Title\n=====\n",
674 "Title\n=====\n\n#### Deep\n",
675 "Sub\n---\n\n#### Deep\n",
676 "T1\n==\nT2\n--\n#### Deep\n",
677 ];
678
679 for input in &inputs {
680 let ctx1 = LintContext::new(input, crate::config::MarkdownFlavor::Standard, None);
681 let fixed_once = rule.fix(&ctx1).unwrap();
682
683 let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
684 let fixed_twice = rule.fix(&ctx2).unwrap();
685
686 assert_eq!(
687 fixed_once, fixed_twice,
688 "fix() is not idempotent for input: {input:?}\nfirst: {fixed_once:?}\nsecond: {fixed_twice:?}"
689 );
690 }
691 }
692
693 #[test]
696 fn test_setext_fix_no_underline_duplication() {
697 let rule = MD001HeadingIncrement::default();
698
699 let content = "Title\n=====\n";
701 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702 let fixed = rule.fix(&ctx).unwrap();
703 assert_eq!(fixed, content, "Valid Setext H1 should be unchanged");
704
705 let content = "Sub\n---\n";
707 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
708 let fixed = rule.fix(&ctx).unwrap();
709 assert_eq!(fixed, content, "Valid Setext H2 should be unchanged");
710
711 let content = "Title\n=====\nSub\n---\n";
713 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714 let fixed = rule.fix(&ctx).unwrap();
715 assert_eq!(fixed, content, "Valid consecutive Setext headings should be unchanged");
716
717 let content = "Title\n=====";
719 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
720 let fixed = rule.fix(&ctx).unwrap();
721 assert_eq!(fixed, content, "Setext H1 at EOF without newline should be unchanged");
722
723 let content = "Sub\n---\n\n#### Deep\n";
725 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
726 let fixed = rule.fix(&ctx).unwrap();
727 assert!(
728 fixed.contains("### Deep"),
729 "H4 after Setext H2 should become H3, got: {fixed}"
730 );
731 assert_eq!(
732 fixed.matches("---").count(),
733 1,
734 "Underline should not be duplicated, got: {fixed}"
735 );
736
737 let content = "Hi\n==========\n";
739 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
740 let fixed = rule.fix(&ctx).unwrap();
741 assert_eq!(
742 fixed, content,
743 "Valid Setext with long underline must be preserved exactly, got: {fixed}"
744 );
745
746 let content = "Long Title Here\n===\n";
748 let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
749 let fixed = rule.fix(&ctx).unwrap();
750 assert_eq!(
751 fixed, content,
752 "Valid Setext with short underline must be preserved exactly, got: {fixed}"
753 );
754 }
755}