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