1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::types::HeadingLevel;
6use crate::utils::range_utils::calculate_match_range;
7use crate::utils::thematic_break;
8use toml;
9
10mod md025_config;
11use md025_config::MD025Config;
12
13#[derive(Clone, Default)]
14pub struct MD025SingleTitle {
15 config: MD025Config,
16}
17
18impl MD025SingleTitle {
19 pub fn new(level: usize, front_matter_title: &str) -> Self {
20 Self {
21 config: MD025Config {
22 level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
23 front_matter_title: front_matter_title.to_string(),
24 allow_document_sections: true,
25 allow_with_separators: true,
26 },
27 }
28 }
29
30 pub fn strict() -> Self {
31 Self {
32 config: MD025Config {
33 level: HeadingLevel::new(1).unwrap(),
34 front_matter_title: "title".to_string(),
35 allow_document_sections: false,
36 allow_with_separators: false,
37 },
38 }
39 }
40
41 pub fn from_config_struct(config: MD025Config) -> Self {
42 Self { config }
43 }
44
45 fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
47 if self.config.front_matter_title.is_empty() {
48 return false;
49 }
50
51 let content_lines = ctx.raw_lines();
52 if content_lines.first().map(|l| l.trim()) != Some("---") {
53 return false;
54 }
55
56 for (idx, line) in content_lines.iter().enumerate().skip(1) {
57 if line.trim() == "---" {
58 let front_matter_content = content_lines[1..idx].join("\n");
59 return front_matter_content
60 .lines()
61 .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
62 }
63 }
64
65 false
66 }
67
68 fn is_document_section_heading(&self, heading_text: &str) -> bool {
70 if !self.config.allow_document_sections {
71 return false;
72 }
73
74 let lower_text = heading_text.to_lowercase();
75
76 let section_indicators = [
78 "appendix",
79 "appendices",
80 "reference",
81 "references",
82 "bibliography",
83 "index",
84 "indices",
85 "glossary",
86 "glossaries",
87 "conclusion",
88 "conclusions",
89 "summary",
90 "executive summary",
91 "acknowledgment",
92 "acknowledgments",
93 "acknowledgement",
94 "acknowledgements",
95 "about",
96 "contact",
97 "license",
98 "legal",
99 "changelog",
100 "change log",
101 "history",
102 "faq",
103 "frequently asked questions",
104 "troubleshooting",
105 "support",
106 "installation",
107 "setup",
108 "getting started",
109 "api reference",
110 "api documentation",
111 "examples",
112 "tutorials",
113 "guides",
114 ];
115
116 let words: Vec<&str> = lower_text.split_whitespace().collect();
118 section_indicators.iter().any(|&indicator| {
119 let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
121 let starts_with_indicator = if indicator_words.len() == 1 {
122 words.first() == Some(&indicator)
123 } else {
124 words.len() >= indicator_words.len()
125 && words[..indicator_words.len()] == indicator_words[..]
126 };
127
128 starts_with_indicator ||
129 lower_text.starts_with(&format!("{indicator}:")) ||
130 words.contains(&indicator) ||
132 (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
134 (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
136 let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
137 matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
138 })
139 })
140 }
141
142 fn is_horizontal_rule(line: &str) -> bool {
143 thematic_break::is_thematic_break(line)
144 }
145
146 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
148 if line_num == 0 || line_num >= ctx.lines.len() {
149 return false;
150 }
151
152 let line = ctx.lines[line_num].content(ctx.content).trim();
153 let prev_line = if line_num > 0 {
154 ctx.lines[line_num - 1].content(ctx.content).trim()
155 } else {
156 ""
157 };
158
159 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
160 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
161 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
162 (is_dash_line || is_equals_line) && prev_line_has_content
163 }
164
165 fn demotion_span(
172 ctx: &crate::lint_context::LintContext,
173 line_num: usize,
174 heading: &crate::lint_context::HeadingInfo,
175 ) -> (std::ops::Range<usize>, String) {
176 let first_idx = line_num + 1 - heading.text_lines;
177 let is_setext = matches!(
178 heading.style,
179 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
180 );
181 let range = if is_setext && line_num + 2 <= ctx.lines.len() {
182 ctx.line_content_byte_range(first_idx + 1).start..ctx.line_content_byte_range(line_num + 2).end
183 } else {
184 ctx.line_content_byte_range(first_idx + 1)
185 };
186 let first_content = ctx.lines[first_idx].content(ctx.content);
187 let leading_spaces = first_content.len() - first_content.trim_start().len();
188 (range, " ".repeat(leading_spaces))
189 }
190
191 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
193 if !self.config.allow_with_separators || heading_line == 0 {
194 return false;
195 }
196
197 let search_start = heading_line.saturating_sub(5);
200
201 for line_num in search_start..heading_line {
202 if line_num >= ctx.lines.len() {
203 continue;
204 }
205
206 let line = &ctx.lines[line_num].content(ctx.content);
207 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
208 let has_intermediate_heading = ((line_num + 1)..heading_line).any(|idx| {
211 idx < ctx.lines.len() && (ctx.lines[idx].heading.is_some() || ctx.lines[idx].is_setext_heading_text)
212 });
213
214 if !has_intermediate_heading {
215 return true;
216 }
217 }
218 }
219
220 false
221 }
222}
223
224impl Rule for MD025SingleTitle {
225 fn name(&self) -> &'static str {
226 "MD025"
227 }
228
229 fn description(&self) -> &'static str {
230 "Multiple top-level headings in the same document"
231 }
232
233 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
234 if ctx.lines.is_empty() {
236 return Ok(Vec::new());
237 }
238
239 let mut warnings = Vec::new();
240
241 let found_title_in_front_matter = self.has_front_matter_title(ctx);
242
243 let mut target_level_headings = Vec::new();
245 for (line_num, line_info) in ctx.lines.iter().enumerate() {
246 if let Some(heading) = &line_info.heading
247 && heading.level as usize == self.config.level.as_usize()
248 && heading.is_valid
249 {
251 if line_info.visual_indent >= 4 || line_info.in_code_block {
253 continue;
254 }
255 target_level_headings.push(line_num);
256 }
257 }
258
259 let headings_to_flag: &[usize] = if found_title_in_front_matter {
264 &target_level_headings
265 } else if target_level_headings.len() > 1 {
266 &target_level_headings[1..]
267 } else {
268 &[]
269 };
270
271 if !headings_to_flag.is_empty() {
272 for &line_num in headings_to_flag {
273 if let Some(heading) = &ctx.lines[line_num].heading {
274 let heading_text = &heading.text;
275 let first_idx = line_num + 1 - heading.text_lines;
278
279 let should_allow = self.is_document_section_heading(heading_text)
281 || self.has_separator_before_heading(ctx, first_idx);
282
283 if should_allow {
284 continue; }
286
287 let line_content = &ctx.lines[line_num].content(ctx.content);
289 let (start_line, start_col, end_line, end_col) = if heading.text_lines > 1 {
290 let first_content = ctx.lines[first_idx].content(ctx.content);
293 let indent_chars = first_content.len() - first_content.trim_start().len();
294 (
295 first_idx + 1,
296 first_content[..indent_chars].chars().count() + 1,
297 line_num + 1,
298 line_content.trim_end().chars().count() + 1,
299 )
300 } else {
301 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
302 pos
303 } else {
304 if line_content.trim_start().starts_with('#') {
306 let trimmed = line_content.trim_start();
307 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
308 let after_hashes = &trimmed[hash_count..];
309 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
310 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
311 } else {
312 0 }
314 };
315 calculate_match_range(
316 line_num + 1, line_content,
318 text_start_in_line,
319 heading_text.len(),
320 )
321 };
322
323 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
324
325 let demoted_level = self.config.level.as_usize() + 1;
329 let fix = if demoted_level > 6 {
330 None
331 } else {
332 let raw = &heading.raw_text;
333 let hashes = "#".repeat(demoted_level);
334 let closing = if heading.has_closing_sequence {
335 format!(" {}", "#".repeat(demoted_level))
336 } else {
337 String::new()
338 };
339 let replacement = if raw.is_empty() {
340 format!("{indentation}{hashes}{closing}")
341 } else {
342 format!("{indentation}{hashes} {raw}{closing}")
343 };
344 Some(Fix::new(fix_range, replacement))
345 };
346
347 warnings.push(LintWarning {
348 rule_name: Some(self.name().to_string()),
349 message: format!(
350 "Multiple top-level headings (level {}) in the same document",
351 self.config.level.as_usize()
352 ),
353 line: start_line,
354 column: start_col,
355 end_line,
356 end_column: end_col,
357 severity: Severity::Error,
358 fix,
359 });
360 }
361 }
362 }
363
364 Ok(warnings)
365 }
366
367 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
368 let warnings = self.check(ctx)?;
369 if warnings.is_empty() {
370 return Ok(ctx.content.to_string());
371 }
372 let warnings =
373 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
374
375 let mut all_warnings = warnings.clone();
380
381 let target_level = self.config.level.as_usize();
382
383 for warning in &warnings {
384 let mut heading_line = warning.line - 1;
388 while heading_line + 1 < ctx.lines.len()
389 && ctx.lines[heading_line].heading.is_none()
390 && ctx.lines[heading_line].is_setext_heading_text
391 {
392 heading_line += 1;
393 }
394
395 let section_end = ctx
397 .lines
398 .iter()
399 .enumerate()
400 .skip(heading_line + 1)
401 .find(|(_, li)| {
402 li.heading.as_ref().is_some_and(|h| {
403 h.level as usize <= target_level && h.is_valid && !li.in_code_block && li.visual_indent < 4
404 })
405 })
406 .map_or(ctx.lines.len(), |(i, _)| i);
407
408 for line_num in (heading_line + 1)..section_end {
410 let line_info = &ctx.lines[line_num];
411 let Some(heading) = &line_info.heading else {
412 continue;
413 };
414 if !heading.is_valid || line_info.in_code_block || line_info.visual_indent >= 4 {
415 continue;
416 }
417
418 let new_level = heading.level as usize + 1;
419 if new_level > 6 {
420 continue;
422 }
423
424 let line_content = line_info.content(ctx.content);
425
426 let (fix_range, indentation) = Self::demotion_span(ctx, line_num, heading);
429 let first_line = line_num + 2 - heading.text_lines;
430
431 let hashes = "#".repeat(new_level);
432 let raw = &heading.raw_text;
433 let closing = if heading.has_closing_sequence {
434 format!(" {}", "#".repeat(new_level))
435 } else {
436 String::new()
437 };
438 let replacement = if raw.is_empty() {
439 format!("{indentation}{hashes}{closing}")
440 } else {
441 format!("{indentation}{hashes} {raw}{closing}")
442 };
443
444 all_warnings.push(crate::rule::LintWarning {
445 rule_name: Some(self.name().to_string()),
446 message: String::new(),
447 line: first_line,
448 column: 1,
449 end_line: line_num + 1,
450 end_column: line_content.chars().count(),
451 severity: crate::rule::Severity::Error,
452 fix: Some(Fix::new(fix_range, replacement)),
453 });
454 }
455 }
456
457 let all_warnings =
461 crate::utils::fix_utils::filter_warnings_by_inline_config(all_warnings, ctx.inline_config(), self.name());
462
463 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &all_warnings)
464 .map_err(crate::rule::LintError::InvalidInput)
465 }
466
467 fn category(&self) -> RuleCategory {
469 RuleCategory::Heading
470 }
471
472 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
474 if ctx.content.is_empty() {
476 return true;
477 }
478
479 if !ctx.likely_has_headings() {
481 return true;
482 }
483
484 let has_fm_title = self.has_front_matter_title(ctx);
485
486 let mut target_level_count = 0;
488 for line_info in &ctx.lines {
489 if let Some(heading) = &line_info.heading
490 && heading.level as usize == self.config.level.as_usize()
491 {
492 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
494 continue;
495 }
496 target_level_count += 1;
497
498 if has_fm_title {
500 return false;
501 }
502
503 if target_level_count > 1 {
505 return false;
506 }
507 }
508 }
509
510 target_level_count <= 1
512 }
513
514 fn as_any(&self) -> &dyn std::any::Any {
515 self
516 }
517
518 crate::impl_rule_config_methods!(MD025Config);
519}
520
521#[cfg(test)]
522mod tests {
523 use super::*;
524
525 #[test]
526 fn test_with_cached_headings() {
527 let rule = MD025SingleTitle::default();
528
529 let content = "# Title\n\n## Section 1\n\n## Section 2";
531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
532 let result = rule.check(&ctx).unwrap();
533 assert!(result.is_empty());
534
535 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
537 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
538 let result = rule.check(&ctx).unwrap();
539 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
541
542 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
544 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
545 let result = rule.check(&ctx).unwrap();
546 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
547 assert_eq!(result[0].line, 5);
548 }
549
550 #[test]
551 fn test_allow_document_sections() {
552 let config = md025_config::MD025Config {
554 allow_document_sections: true,
555 ..Default::default()
556 };
557 let rule = MD025SingleTitle::from_config_struct(config);
558
559 let valid_cases = vec![
561 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
562 "# Introduction\n\nContent here\n\n# References\n\nRef content",
563 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
564 "# Manual\n\nContent\n\n# Index\n\nIndex content",
565 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
566 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
567 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
568 ];
569
570 for case in valid_cases {
571 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
572 let result = rule.check(&ctx).unwrap();
573 assert!(result.is_empty(), "Should not flag document sections in: {case}");
574 }
575
576 let invalid_cases = vec![
578 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
579 "# First\n\nContent\n\n# Second Title\n\nMore content",
580 ];
581
582 for case in invalid_cases {
583 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
586 }
587 }
588
589 #[test]
590 fn test_strict_mode() {
591 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
595 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
596 let result = rule.check(&ctx).unwrap();
597 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
598 }
599
600 #[test]
601 fn test_bounds_checking_bug() {
602 let rule = MD025SingleTitle::default();
605
606 let content = "# First\n#";
608 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
609
610 let result = rule.check(&ctx);
612 assert!(result.is_ok());
613
614 let fix_result = rule.fix(&ctx);
616 assert!(fix_result.is_ok());
617 }
618
619 #[test]
620 fn test_bounds_checking_edge_case() {
621 let rule = MD025SingleTitle::default();
624
625 let content = "# First Title\n#";
629 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
630
631 let result = rule.check(&ctx);
633 assert!(result.is_ok());
634
635 if let Ok(warnings) = result
636 && !warnings.is_empty()
637 {
638 let fix_result = rule.fix(&ctx);
640 assert!(fix_result.is_ok());
641
642 if let Ok(fixed_content) = fix_result {
644 assert!(!fixed_content.is_empty());
645 assert!(fixed_content.contains("##"));
647 }
648 }
649 }
650
651 #[test]
652 fn test_horizontal_rule_separators() {
653 let config = md025_config::MD025Config {
655 allow_with_separators: true,
656 ..Default::default()
657 };
658 let rule = MD025SingleTitle::from_config_struct(config);
659
660 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n***\n\n# Third Title\n\nFinal content.";
662 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
663 let result = rule.check(&ctx).unwrap();
664 assert!(
665 result.is_empty(),
666 "Should not flag headings separated by horizontal rules"
667 );
668
669 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.\n\n# Third Title\n\nNo separator before this one.";
671 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
672 let result = rule.check(&ctx).unwrap();
673 assert_eq!(result.len(), 1, "Should flag the heading without separator");
674 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
678 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = strict_rule.check(&ctx).unwrap();
681 assert_eq!(
682 result.len(),
683 1,
684 "Strict mode should flag all multiple H1s regardless of separators"
685 );
686 }
687
688 #[test]
689 fn test_python_comments_in_code_blocks() {
690 let rule = MD025SingleTitle::default();
691
692 let content = "# Main Title\n\n```python\n# This is a Python comment, not a heading\nprint('Hello')\n```\n\n## Section\n\nMore content.";
694 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
695 let result = rule.check(&ctx).unwrap();
696 assert!(
697 result.is_empty(),
698 "Should not flag Python comments in code blocks as headings"
699 );
700
701 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
703 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
704 let fixed = rule.fix(&ctx).unwrap();
705 assert!(
706 fixed.contains("# Python comment"),
707 "Fix should preserve Python comments in code blocks"
708 );
709 assert!(
710 fixed.contains("## Second Title"),
711 "Fix should demote the actual second heading"
712 );
713 }
714
715 #[test]
716 fn test_fix_preserves_attribute_lists() {
717 let rule = MD025SingleTitle::strict();
718
719 let content = "# First Title\n\n# Second Title { #custom-id .special }";
721 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
722
723 let warnings = rule.check(&ctx).unwrap();
725 assert_eq!(warnings.len(), 1);
726 assert!(warnings[0].fix.is_some());
728
729 let fixed = rule.fix(&ctx).unwrap();
731 assert!(
732 fixed.contains("## Second Title { #custom-id .special }"),
733 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
734 );
735 }
736
737 #[test]
738 fn test_frontmatter_title_counts_as_h1() {
739 let rule = MD025SingleTitle::default();
740
741 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
743 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
744 let result = rule.check(&ctx).unwrap();
745 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
746 assert_eq!(result[0].line, 5);
747 }
748
749 #[test]
750 fn test_frontmatter_title_with_multiple_body_h1s() {
751 let config = md025_config::MD025Config {
752 front_matter_title: "title".to_string(),
753 ..Default::default()
754 };
755 let rule = MD025SingleTitle::from_config_struct(config);
756
757 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
759 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
760 let result = rule.check(&ctx).unwrap();
761 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
762 assert_eq!(result[0].line, 5);
763 assert_eq!(result[1].line, 9);
764 }
765
766 #[test]
767 fn test_frontmatter_without_title_no_warning() {
768 let rule = MD025SingleTitle::default();
769
770 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = rule.check(&ctx).unwrap();
774 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
775 }
776
777 #[test]
778 fn test_no_frontmatter_single_h1_no_warning() {
779 let rule = MD025SingleTitle::default();
780
781 let content = "# Only Heading\n\nSome content.";
783 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
784 let result = rule.check(&ctx).unwrap();
785 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
786 }
787
788 #[test]
789 fn test_frontmatter_custom_title_key() {
790 let config = md025_config::MD025Config {
792 front_matter_title: "heading".to_string(),
793 ..Default::default()
794 };
795 let rule = MD025SingleTitle::from_config_struct(config);
796
797 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
799 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
800 let result = rule.check(&ctx).unwrap();
801 assert_eq!(
802 result.len(),
803 1,
804 "Should flag body H1 when custom frontmatter key matches"
805 );
806 assert_eq!(result[0].line, 5);
807
808 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
810 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
811 let result = rule.check(&ctx).unwrap();
812 assert!(
813 result.is_empty(),
814 "Should not flag when frontmatter key doesn't match config"
815 );
816 }
817
818 #[test]
819 fn test_frontmatter_title_empty_config_disables() {
820 let rule = MD025SingleTitle::new(1, "");
822
823 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
827 }
828
829 #[test]
830 fn test_frontmatter_title_with_level_config() {
831 let config = md025_config::MD025Config {
833 level: HeadingLevel::new(2).unwrap(),
834 front_matter_title: "title".to_string(),
835 ..Default::default()
836 };
837 let rule = MD025SingleTitle::from_config_struct(config);
838
839 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
842 let result = rule.check(&ctx).unwrap();
843 assert_eq!(
844 result.len(),
845 1,
846 "Should flag body H2 when level=2 and frontmatter has title"
847 );
848 assert_eq!(result[0].line, 7);
849 }
850
851 #[test]
852 fn test_frontmatter_title_fix_demotes_body_heading() {
853 let config = md025_config::MD025Config {
854 front_matter_title: "title".to_string(),
855 ..Default::default()
856 };
857 let rule = MD025SingleTitle::from_config_struct(config);
858
859 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
860 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
861 let fixed = rule.fix(&ctx).unwrap();
862 assert!(
863 fixed.contains("## Body Heading"),
864 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
865 );
866 assert!(fixed.contains("---\ntitle: FM Title\n---"));
868 }
869
870 #[test]
871 fn test_frontmatter_title_should_skip_respects_frontmatter() {
872 let rule = MD025SingleTitle::default();
873
874 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
876 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 assert!(
878 !rule.should_skip(&ctx),
879 "should_skip must return false when frontmatter has title and body has H1"
880 );
881
882 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
884 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
885 assert!(
886 rule.should_skip(&ctx),
887 "should_skip should return true with no frontmatter title and single H1"
888 );
889 }
890
891 #[test]
892 fn test_fix_cascades_subheadings_after_demoting_duplicate_h1() {
893 let rule = MD025SingleTitle::default();
894
895 let content = "abcd\n\n# 1_1\n\n# 1_2\n\n## 1_2-2_1\n\n# 1_3\n\n## 1_3-2_1\n\n### 1_3-2_1-3_1\n";
897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
898 let fixed = rule.fix(&ctx).unwrap();
899
900 assert!(fixed.contains("# 1_1"), "First H1 must be preserved: {fixed}");
901 assert!(
902 fixed.contains("## 1_2\n"),
903 "Duplicate H1 must be demoted to H2: {fixed}"
904 );
905 assert!(
906 fixed.contains("### 1_2-2_1"),
907 "H2 under demoted H1 must cascade to H3: {fixed}"
908 );
909 assert!(fixed.contains("## 1_3\n"), "Third H1 must be demoted to H2: {fixed}");
910 assert!(
911 fixed.contains("### 1_3-2_1"),
912 "H2 under third demoted H1 must cascade to H3: {fixed}"
913 );
914 assert!(
915 fixed.contains("#### 1_3-2_1-3_1"),
916 "H3 under third demoted H1 must cascade to H4: {fixed}"
917 );
918 }
919
920 #[test]
921 fn test_fix_cascades_single_section_only() {
922 let rule = MD025SingleTitle::default();
923
924 let content = "# Main\n\n# Alpha\n\n## Alpha Sub\n\n# Beta\n\n## Beta Sub\n";
926 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
927 let fixed = rule.fix(&ctx).unwrap();
928
929 assert!(fixed.contains("# Main\n"), "First H1 preserved: {fixed}");
930 assert!(fixed.contains("## Alpha\n"), "Alpha H1 demoted to H2: {fixed}");
931 assert!(fixed.contains("### Alpha Sub"), "Alpha Sub cascades to H3: {fixed}");
932 assert!(fixed.contains("## Beta\n"), "Beta H1 demoted to H2: {fixed}");
933 assert!(fixed.contains("### Beta Sub"), "Beta Sub cascades to H3: {fixed}");
934 }
935
936 #[test]
937 fn test_fix_cascade_stops_at_next_same_level() {
938 let rule = MD025SingleTitle::default();
939
940 let content = "# Main\n\n# A\n\n## A1\n\n# B\n\n## B1\n\n### B1a\n";
944 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
945 let fixed = rule.fix(&ctx).unwrap();
946
947 assert!(fixed.contains("## A\n"), "A demoted to H2: {fixed}");
948 assert!(fixed.contains("### A1"), "A1 cascades to H3: {fixed}");
949 assert!(fixed.contains("## B\n"), "B demoted to H2: {fixed}");
950 assert!(fixed.contains("### B1"), "B1 cascades to H3: {fixed}");
951 assert!(fixed.contains("#### B1a"), "B1a cascades to H4: {fixed}");
952 assert!(fixed.contains("# Main"), "Main preserved at H1: {fixed}");
954 }
955
956 #[test]
957 fn test_fix_cascade_does_not_exceed_level_6() {
958 let rule = MD025SingleTitle::default();
960
961 let content = "# Title\n\n# Section\n\n## L2\n\n### L3\n\n#### L4\n\n##### L5\n\n###### L6\n";
963 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
964 let fixed = rule.fix(&ctx).unwrap();
965
966 assert!(fixed.contains("# Title"), "First H1 preserved: {fixed}");
967 assert!(fixed.contains("## Section"), "Section demoted to H2: {fixed}");
968 assert!(fixed.contains("### L2"), "L2 cascades to H3: {fixed}");
969 assert!(fixed.contains("#### L3"), "L3 cascades to H4: {fixed}");
970 assert!(fixed.contains("##### L4"), "L4 cascades to H5: {fixed}");
971 assert!(fixed.contains("###### L5"), "L5 cascades to H6: {fixed}");
972 assert!(fixed.contains("###### L6"), "L6 at max depth stays at H6: {fixed}");
974 }
975
976 #[test]
977 fn test_fix_cascade_respects_inline_disable_on_subordinate() {
978 let rule = MD025SingleTitle::default();
981
982 let content = "# Title\n# Demote\n## Skip <!-- markdownlint-disable-line MD025 -->\n## Cascade\n";
983 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984 let fixed = rule.fix(&ctx).unwrap();
985
986 assert!(fixed.contains("## Demote"), "Duplicate H1 should be demoted: {fixed}");
987 let skip_line = fixed.lines().find(|l| l.contains("Skip")).unwrap_or("");
990 assert!(
991 skip_line.starts_with("## Skip"),
992 "Inline-disabled subordinate should stay at level 2, got line: {skip_line:?}"
993 );
994 assert!(
996 fixed.contains("### Cascade"),
997 "Non-disabled subordinate should cascade to level 3: {fixed}"
998 );
999 }
1000
1001 #[test]
1002 fn test_section_indicator_whole_word_matching() {
1003 let config = md025_config::MD025Config {
1005 allow_document_sections: true,
1006 ..Default::default()
1007 };
1008 let rule = MD025SingleTitle::from_config_struct(config);
1009
1010 let false_positive_cases = vec![
1012 "# Main Title\n\n# Understanding Reindex Operations",
1013 "# Main Title\n\n# The Summarization Pipeline",
1014 "# Main Title\n\n# Data Indexing Strategy",
1015 "# Main Title\n\n# Unsupported Browsers",
1016 ];
1017
1018 for case in false_positive_cases {
1019 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1020 let result = rule.check(&ctx).unwrap();
1021 assert_eq!(
1022 result.len(),
1023 1,
1024 "Should flag duplicate H1 (not a section indicator): {case}"
1025 );
1026 }
1027
1028 let true_positive_cases = vec![
1030 "# Main Title\n\n# Index",
1031 "# Main Title\n\n# Summary",
1032 "# Main Title\n\n# About",
1033 "# Main Title\n\n# References",
1034 ];
1035
1036 for case in true_positive_cases {
1037 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1038 let result = rule.check(&ctx).unwrap();
1039 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1040 }
1041 }
1042
1043 #[test]
1044 fn test_mdg_enforces_single_title() {
1045 let rule = MD025SingleTitle::strict();
1048 let content = "# Feature: Checkout\n\n# Rule: Registered customers\n\n# Scenario: Purchase\n";
1049
1050 let standard_ctx =
1051 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1052 let mdg_ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::MDG, None);
1053
1054 assert_eq!(rule.check(&mdg_ctx).unwrap().len(), 2);
1055 assert_eq!(
1056 rule.check(&mdg_ctx).unwrap().len(),
1057 rule.check(&standard_ctx).unwrap().len(),
1058 "MDG must not differ from Standard"
1059 );
1060
1061 let fixed = rule.fix(&mdg_ctx).unwrap();
1062 assert_eq!(
1063 fixed, "# Feature: Checkout\n\n## Rule: Registered customers\n\n## Scenario: Purchase\n",
1064 "the Gherkin keywords must survive the demotion"
1065 );
1066
1067 let fixed_ctx = crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::MDG, None);
1068 assert_eq!(rule.fix(&fixed_ctx).unwrap(), fixed, "MDG fix should be idempotent");
1069 }
1070}