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::regex_cache::{
8 HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
9};
10use toml;
11
12mod md025_config;
13use md025_config::MD025Config;
14
15#[derive(Clone, Default)]
16pub struct MD025SingleTitle {
17 config: MD025Config,
18}
19
20impl MD025SingleTitle {
21 pub fn new(level: usize, front_matter_title: &str) -> Self {
22 Self {
23 config: MD025Config {
24 level: HeadingLevel::new(level as u8).expect("Level must be 1-6"),
25 front_matter_title: front_matter_title.to_string(),
26 allow_document_sections: true,
27 allow_with_separators: true,
28 },
29 }
30 }
31
32 pub fn strict() -> Self {
33 Self {
34 config: MD025Config {
35 level: HeadingLevel::new(1).unwrap(),
36 front_matter_title: "title".to_string(),
37 allow_document_sections: false,
38 allow_with_separators: false,
39 },
40 }
41 }
42
43 pub fn from_config_struct(config: MD025Config) -> Self {
44 Self { config }
45 }
46
47 fn has_front_matter_title(&self, ctx: &crate::lint_context::LintContext) -> bool {
49 if self.config.front_matter_title.is_empty() {
50 return false;
51 }
52
53 let content_lines = ctx.raw_lines();
54 if content_lines.first().map(|l| l.trim()) != Some("---") {
55 return false;
56 }
57
58 for (idx, line) in content_lines.iter().enumerate().skip(1) {
59 if line.trim() == "---" {
60 let front_matter_content = content_lines[1..idx].join("\n");
61 return front_matter_content
62 .lines()
63 .any(|l| l.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
64 }
65 }
66
67 false
68 }
69
70 fn is_document_section_heading(&self, heading_text: &str) -> bool {
72 if !self.config.allow_document_sections {
73 return false;
74 }
75
76 let lower_text = heading_text.to_lowercase();
77
78 let section_indicators = [
80 "appendix",
81 "appendices",
82 "reference",
83 "references",
84 "bibliography",
85 "index",
86 "indices",
87 "glossary",
88 "glossaries",
89 "conclusion",
90 "conclusions",
91 "summary",
92 "executive summary",
93 "acknowledgment",
94 "acknowledgments",
95 "acknowledgement",
96 "acknowledgements",
97 "about",
98 "contact",
99 "license",
100 "legal",
101 "changelog",
102 "change log",
103 "history",
104 "faq",
105 "frequently asked questions",
106 "troubleshooting",
107 "support",
108 "installation",
109 "setup",
110 "getting started",
111 "api reference",
112 "api documentation",
113 "examples",
114 "tutorials",
115 "guides",
116 ];
117
118 let words: Vec<&str> = lower_text.split_whitespace().collect();
120 section_indicators.iter().any(|&indicator| {
121 let indicator_words: Vec<&str> = indicator.split_whitespace().collect();
123 let starts_with_indicator = if indicator_words.len() == 1 {
124 words.first() == Some(&indicator)
125 } else {
126 words.len() >= indicator_words.len()
127 && words[..indicator_words.len()] == indicator_words[..]
128 };
129
130 starts_with_indicator ||
131 lower_text.starts_with(&format!("{indicator}:")) ||
132 words.contains(&indicator) ||
134 (indicator_words.len() > 1 && words.windows(indicator_words.len()).any(|w| w == indicator_words.as_slice())) ||
136 (indicator == "appendix" && words.contains(&"appendix") && words.len() >= 2 && {
138 let after_appendix = words.iter().skip_while(|&&w| w != "appendix").nth(1);
139 matches!(after_appendix, Some(&"a" | &"b" | &"c" | &"d" | &"1" | &"2" | &"3" | &"i" | &"ii" | &"iii" | &"iv"))
140 })
141 })
142 }
143
144 fn is_horizontal_rule(line: &str) -> bool {
146 let trimmed = line.trim();
147 HR_DASH.is_match(trimmed)
148 || HR_ASTERISK.is_match(trimmed)
149 || HR_UNDERSCORE.is_match(trimmed)
150 || HR_SPACED_DASH.is_match(trimmed)
151 || HR_SPACED_ASTERISK.is_match(trimmed)
152 || HR_SPACED_UNDERSCORE.is_match(trimmed)
153 }
154
155 fn build_demoted_heading(
158 heading: &crate::lint_context::types::HeadingInfo,
159 line_info: &crate::lint_context::types::LineInfo,
160 content: &str,
161 delta: usize,
162 ) -> (String, bool) {
163 let new_level = (heading.level as usize + delta).min(6);
164
165 let style = match heading.style {
166 crate::lint_context::HeadingStyle::ATX => {
167 if heading.has_closing_sequence {
168 crate::rules::heading_utils::HeadingStyle::AtxClosed
169 } else {
170 crate::rules::heading_utils::HeadingStyle::Atx
171 }
172 }
173 crate::lint_context::HeadingStyle::Setext1 => {
174 if new_level <= 2 {
175 if new_level == 1 {
176 crate::rules::heading_utils::HeadingStyle::Setext1
177 } else {
178 crate::rules::heading_utils::HeadingStyle::Setext2
179 }
180 } else {
181 crate::rules::heading_utils::HeadingStyle::Atx
182 }
183 }
184 crate::lint_context::HeadingStyle::Setext2 => {
185 if new_level <= 2 {
186 crate::rules::heading_utils::HeadingStyle::Setext2
187 } else {
188 crate::rules::heading_utils::HeadingStyle::Atx
189 }
190 }
191 };
192
193 let replacement = if heading.text.is_empty() {
194 match style {
195 crate::rules::heading_utils::HeadingStyle::Atx
196 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => "#".repeat(new_level),
197 crate::rules::heading_utils::HeadingStyle::AtxClosed
198 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
199 format!("{} {}", "#".repeat(new_level), "#".repeat(new_level))
200 }
201 crate::rules::heading_utils::HeadingStyle::Setext1
202 | crate::rules::heading_utils::HeadingStyle::Setext2
203 | crate::rules::heading_utils::HeadingStyle::Consistent => "#".repeat(new_level),
204 }
205 } else {
206 crate::rules::heading_utils::HeadingUtils::convert_heading_style(&heading.raw_text, new_level as u32, style)
207 };
208
209 let line = line_info.content(content);
210 let original_indent = &line[..line_info.indent];
211 let result = format!("{original_indent}{replacement}");
212
213 let should_skip_next = matches!(
214 heading.style,
215 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
216 );
217
218 (result, should_skip_next)
219 }
220
221 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
223 if line_num == 0 || line_num >= ctx.lines.len() {
224 return false;
225 }
226
227 let line = ctx.lines[line_num].content(ctx.content).trim();
228 let prev_line = if line_num > 0 {
229 ctx.lines[line_num - 1].content(ctx.content).trim()
230 } else {
231 ""
232 };
233
234 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
235 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
236 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
237 (is_dash_line || is_equals_line) && prev_line_has_content
238 }
239
240 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
242 if !self.config.allow_with_separators || heading_line == 0 {
243 return false;
244 }
245
246 let search_start = heading_line.saturating_sub(5);
249
250 for line_num in search_start..heading_line {
251 if line_num >= ctx.lines.len() {
252 continue;
253 }
254
255 let line = &ctx.lines[line_num].content(ctx.content);
256 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
257 let has_intermediate_heading =
260 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
261
262 if !has_intermediate_heading {
263 return true;
264 }
265 }
266 }
267
268 false
269 }
270}
271
272impl Rule for MD025SingleTitle {
273 fn name(&self) -> &'static str {
274 "MD025"
275 }
276
277 fn description(&self) -> &'static str {
278 "Multiple top-level headings in the same document"
279 }
280
281 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
282 if ctx.lines.is_empty() {
284 return Ok(Vec::new());
285 }
286
287 let mut warnings = Vec::new();
288
289 let found_title_in_front_matter = self.has_front_matter_title(ctx);
290
291 let mut target_level_headings = Vec::new();
293 for (line_num, line_info) in ctx.lines.iter().enumerate() {
294 if let Some(heading) = &line_info.heading
295 && heading.level as usize == self.config.level.as_usize()
296 && heading.is_valid
297 {
299 if line_info.visual_indent >= 4 || line_info.in_code_block {
301 continue;
302 }
303 target_level_headings.push(line_num);
304 }
305 }
306
307 let headings_to_flag: &[usize] = if found_title_in_front_matter {
312 &target_level_headings
313 } else if target_level_headings.len() > 1 {
314 &target_level_headings[1..]
315 } else {
316 &[]
317 };
318
319 if !headings_to_flag.is_empty() {
320 for &line_num in headings_to_flag {
321 if let Some(heading) = &ctx.lines[line_num].heading {
322 let heading_text = &heading.text;
323
324 let should_allow = self.is_document_section_heading(heading_text)
326 || self.has_separator_before_heading(ctx, line_num);
327
328 if should_allow {
329 continue; }
331
332 let line_content = &ctx.lines[line_num].content(ctx.content);
334 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
335 pos
336 } else {
337 if line_content.trim_start().starts_with('#') {
339 let trimmed = line_content.trim_start();
340 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
341 let after_hashes = &trimmed[hash_count..];
342 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
343 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
344 } else {
345 0 }
347 };
348
349 let (start_line, start_col, end_line, end_col) = calculate_match_range(
350 line_num + 1, line_content,
352 text_start_in_line,
353 heading_text.len(),
354 );
355
356 let is_setext = matches!(
359 heading.style,
360 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
361 );
362 let fix_range = if is_setext && line_num + 2 <= ctx.lines.len() {
363 let text_range = ctx.line_index.line_content_range(line_num + 1);
365 let underline_range = ctx.line_index.line_content_range(line_num + 2);
366 text_range.start..underline_range.end
367 } else {
368 ctx.line_index.line_content_range(line_num + 1)
369 };
370
371 let replacement = {
372 let leading_spaces = line_content.len() - line_content.trim_start().len();
373 let indentation = " ".repeat(leading_spaces);
374 let raw = &heading.raw_text;
375 if raw.is_empty() {
376 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
377 } else {
378 format!(
379 "{}{} {}",
380 indentation,
381 "#".repeat(self.config.level.as_usize() + 1),
382 raw
383 )
384 }
385 };
386
387 warnings.push(LintWarning {
388 rule_name: Some(self.name().to_string()),
389 message: format!(
390 "Multiple top-level headings (level {}) in the same document",
391 self.config.level.as_usize()
392 ),
393 line: start_line,
394 column: start_col,
395 end_line,
396 end_column: end_col,
397 severity: Severity::Error,
398 fix: Some(Fix {
399 range: fix_range,
400 replacement,
401 }),
402 });
403 }
404 }
405 }
406
407 Ok(warnings)
408 }
409
410 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
411 let mut fixed_lines = Vec::new();
412 let mut found_first = self.has_front_matter_title(ctx);
415 let mut skip_next = false;
416 let mut current_delta: usize = 0;
420
421 for (line_num, line_info) in ctx.lines.iter().enumerate() {
422 if skip_next {
423 skip_next = false;
424 continue;
425 }
426
427 if ctx.inline_config().is_rule_disabled(self.name(), line_num + 1) {
429 fixed_lines.push(line_info.content(ctx.content).to_string());
430 if let Some(heading) = &line_info.heading {
431 if heading.level as usize == self.config.level.as_usize() {
434 current_delta = 0;
435 }
436 if matches!(
438 heading.style,
439 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
440 ) && line_num + 1 < ctx.lines.len()
441 {
442 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
443 skip_next = true;
444 }
445 }
446 continue;
447 }
448
449 if let Some(heading) = &line_info.heading {
450 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
451 if !found_first {
452 found_first = true;
453 current_delta = 0;
454 fixed_lines.push(line_info.content(ctx.content).to_string());
456
457 if matches!(
459 heading.style,
460 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
461 ) && line_num + 1 < ctx.lines.len()
462 {
463 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
464 skip_next = true;
465 }
466 } else {
467 let should_allow = self.is_document_section_heading(&heading.text)
469 || self.has_separator_before_heading(ctx, line_num);
470
471 if should_allow {
472 current_delta = 0;
473 fixed_lines.push(line_info.content(ctx.content).to_string());
475
476 if matches!(
478 heading.style,
479 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
480 ) && line_num + 1 < ctx.lines.len()
481 {
482 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
483 skip_next = true;
484 }
485 } else {
486 current_delta = 1;
488 let (demoted, should_skip) =
489 Self::build_demoted_heading(heading, line_info, ctx.content, 1);
490 fixed_lines.push(demoted);
491 if should_skip && line_num + 1 < ctx.lines.len() {
492 skip_next = true;
493 }
494 }
495 }
496 } else if current_delta > 0 && !line_info.in_code_block {
497 let new_level = heading.level as usize + current_delta;
499 if new_level > 6 {
500 fixed_lines.push(line_info.content(ctx.content).to_string());
502 if matches!(
503 heading.style,
504 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
505 ) && line_num + 1 < ctx.lines.len()
506 {
507 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
508 skip_next = true;
509 }
510 } else {
511 let (demoted, should_skip) =
512 Self::build_demoted_heading(heading, line_info, ctx.content, current_delta);
513 fixed_lines.push(demoted);
514 if should_skip && line_num + 1 < ctx.lines.len() {
515 skip_next = true;
516 }
517 }
518 } else {
519 fixed_lines.push(line_info.content(ctx.content).to_string());
521
522 if matches!(
524 heading.style,
525 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
526 ) && line_num + 1 < ctx.lines.len()
527 {
528 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
529 skip_next = true;
530 }
531 }
532 } else {
533 fixed_lines.push(line_info.content(ctx.content).to_string());
535 }
536 }
537
538 let result = fixed_lines.join("\n");
539 if ctx.content.ends_with('\n') {
540 Ok(result + "\n")
541 } else {
542 Ok(result)
543 }
544 }
545
546 fn category(&self) -> RuleCategory {
548 RuleCategory::Heading
549 }
550
551 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
553 if ctx.content.is_empty() {
555 return true;
556 }
557
558 if !ctx.likely_has_headings() {
560 return true;
561 }
562
563 let has_fm_title = self.has_front_matter_title(ctx);
564
565 let mut target_level_count = 0;
567 for line_info in &ctx.lines {
568 if let Some(heading) = &line_info.heading
569 && heading.level as usize == self.config.level.as_usize()
570 {
571 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
573 continue;
574 }
575 target_level_count += 1;
576
577 if has_fm_title {
579 return false;
580 }
581
582 if target_level_count > 1 {
584 return false;
585 }
586 }
587 }
588
589 target_level_count <= 1
591 }
592
593 fn as_any(&self) -> &dyn std::any::Any {
594 self
595 }
596
597 fn default_config_section(&self) -> Option<(String, toml::Value)> {
598 let json_value = serde_json::to_value(&self.config).ok()?;
599 Some((
600 self.name().to_string(),
601 crate::rule_config_serde::json_to_toml_value(&json_value)?,
602 ))
603 }
604
605 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
606 where
607 Self: Sized,
608 {
609 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
610 Box::new(Self::from_config_struct(rule_config))
611 }
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 #[test]
619 fn test_with_cached_headings() {
620 let rule = MD025SingleTitle::default();
621
622 let content = "# Title\n\n## Section 1\n\n## Section 2";
624 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
625 let result = rule.check(&ctx).unwrap();
626 assert!(result.is_empty());
627
628 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
630 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
631 let result = rule.check(&ctx).unwrap();
632 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
634
635 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
637 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
638 let result = rule.check(&ctx).unwrap();
639 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
640 assert_eq!(result[0].line, 5);
641 }
642
643 #[test]
644 fn test_allow_document_sections() {
645 let config = md025_config::MD025Config {
647 allow_document_sections: true,
648 ..Default::default()
649 };
650 let rule = MD025SingleTitle::from_config_struct(config);
651
652 let valid_cases = vec![
654 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
655 "# Introduction\n\nContent here\n\n# References\n\nRef content",
656 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
657 "# Manual\n\nContent\n\n# Index\n\nIndex content",
658 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
659 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
660 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
661 ];
662
663 for case in valid_cases {
664 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
665 let result = rule.check(&ctx).unwrap();
666 assert!(result.is_empty(), "Should not flag document sections in: {case}");
667 }
668
669 let invalid_cases = vec![
671 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
672 "# First\n\nContent\n\n# Second Title\n\nMore content",
673 ];
674
675 for case in invalid_cases {
676 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
677 let result = rule.check(&ctx).unwrap();
678 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
679 }
680 }
681
682 #[test]
683 fn test_strict_mode() {
684 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
688 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let result = rule.check(&ctx).unwrap();
690 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
691 }
692
693 #[test]
694 fn test_bounds_checking_bug() {
695 let rule = MD025SingleTitle::default();
698
699 let content = "# First\n#";
701 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
702
703 let result = rule.check(&ctx);
705 assert!(result.is_ok());
706
707 let fix_result = rule.fix(&ctx);
709 assert!(fix_result.is_ok());
710 }
711
712 #[test]
713 fn test_bounds_checking_edge_case() {
714 let rule = MD025SingleTitle::default();
717
718 let content = "# First Title\n#";
722 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
723
724 let result = rule.check(&ctx);
726 assert!(result.is_ok());
727
728 if let Ok(warnings) = result
729 && !warnings.is_empty()
730 {
731 let fix_result = rule.fix(&ctx);
733 assert!(fix_result.is_ok());
734
735 if let Ok(fixed_content) = fix_result {
737 assert!(!fixed_content.is_empty());
738 assert!(fixed_content.contains("##"));
740 }
741 }
742 }
743
744 #[test]
745 fn test_horizontal_rule_separators() {
746 let config = md025_config::MD025Config {
748 allow_with_separators: true,
749 ..Default::default()
750 };
751 let rule = MD025SingleTitle::from_config_struct(config);
752
753 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.";
755 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
756 let result = rule.check(&ctx).unwrap();
757 assert!(
758 result.is_empty(),
759 "Should not flag headings separated by horizontal rules"
760 );
761
762 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.";
764 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
765 let result = rule.check(&ctx).unwrap();
766 assert_eq!(result.len(), 1, "Should flag the heading without separator");
767 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
771 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
773 let result = strict_rule.check(&ctx).unwrap();
774 assert_eq!(
775 result.len(),
776 1,
777 "Strict mode should flag all multiple H1s regardless of separators"
778 );
779 }
780
781 #[test]
782 fn test_python_comments_in_code_blocks() {
783 let rule = MD025SingleTitle::default();
784
785 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.";
787 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
788 let result = rule.check(&ctx).unwrap();
789 assert!(
790 result.is_empty(),
791 "Should not flag Python comments in code blocks as headings"
792 );
793
794 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
796 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
797 let fixed = rule.fix(&ctx).unwrap();
798 assert!(
799 fixed.contains("# Python comment"),
800 "Fix should preserve Python comments in code blocks"
801 );
802 assert!(
803 fixed.contains("## Second Title"),
804 "Fix should demote the actual second heading"
805 );
806 }
807
808 #[test]
809 fn test_fix_preserves_attribute_lists() {
810 let rule = MD025SingleTitle::strict();
811
812 let content = "# First Title\n\n# Second Title { #custom-id .special }";
814 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
815
816 let warnings = rule.check(&ctx).unwrap();
818 assert_eq!(warnings.len(), 1);
819 assert!(warnings[0].fix.is_some());
821
822 let fixed = rule.fix(&ctx).unwrap();
824 assert!(
825 fixed.contains("## Second Title { #custom-id .special }"),
826 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
827 );
828 }
829
830 #[test]
831 fn test_frontmatter_title_counts_as_h1() {
832 let rule = MD025SingleTitle::default();
833
834 let content = "---\ntitle: Heading in frontmatter\n---\n\n# Heading in document\n\nSome introductory text.";
836 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
837 let result = rule.check(&ctx).unwrap();
838 assert_eq!(result.len(), 1, "Should flag body H1 when frontmatter has title");
839 assert_eq!(result[0].line, 5);
840 }
841
842 #[test]
843 fn test_frontmatter_title_with_multiple_body_h1s() {
844 let config = md025_config::MD025Config {
845 front_matter_title: "title".to_string(),
846 ..Default::default()
847 };
848 let rule = MD025SingleTitle::from_config_struct(config);
849
850 let content = "---\ntitle: FM Title\n---\n\n# First Body H1\n\nContent\n\n# Second Body H1\n\nMore content";
852 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
853 let result = rule.check(&ctx).unwrap();
854 assert_eq!(result.len(), 2, "Should flag all body H1s when frontmatter has title");
855 assert_eq!(result[0].line, 5);
856 assert_eq!(result[1].line, 9);
857 }
858
859 #[test]
860 fn test_frontmatter_without_title_no_warning() {
861 let rule = MD025SingleTitle::default();
862
863 let content = "---\nauthor: Someone\ndate: 2024-01-01\n---\n\n# Only Heading\n\nContent here.";
865 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866 let result = rule.check(&ctx).unwrap();
867 assert!(result.is_empty(), "Should not flag when frontmatter has no title");
868 }
869
870 #[test]
871 fn test_no_frontmatter_single_h1_no_warning() {
872 let rule = MD025SingleTitle::default();
873
874 let content = "# Only Heading\n\nSome content.";
876 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
877 let result = rule.check(&ctx).unwrap();
878 assert!(result.is_empty(), "Should not flag single H1 without frontmatter");
879 }
880
881 #[test]
882 fn test_frontmatter_custom_title_key() {
883 let config = md025_config::MD025Config {
885 front_matter_title: "heading".to_string(),
886 ..Default::default()
887 };
888 let rule = MD025SingleTitle::from_config_struct(config);
889
890 let content = "---\nheading: My Heading\n---\n\n# Body Heading\n\nContent.";
892 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893 let result = rule.check(&ctx).unwrap();
894 assert_eq!(
895 result.len(),
896 1,
897 "Should flag body H1 when custom frontmatter key matches"
898 );
899 assert_eq!(result[0].line, 5);
900
901 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
903 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904 let result = rule.check(&ctx).unwrap();
905 assert!(
906 result.is_empty(),
907 "Should not flag when frontmatter key doesn't match config"
908 );
909 }
910
911 #[test]
912 fn test_frontmatter_title_empty_config_disables() {
913 let rule = MD025SingleTitle::new(1, "");
915
916 let content = "---\ntitle: My Title\n---\n\n# Body Heading\n\nContent.";
917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919 assert!(result.is_empty(), "Should not flag when front_matter_title is empty");
920 }
921
922 #[test]
923 fn test_frontmatter_title_with_level_config() {
924 let config = md025_config::MD025Config {
926 level: HeadingLevel::new(2).unwrap(),
927 front_matter_title: "title".to_string(),
928 ..Default::default()
929 };
930 let rule = MD025SingleTitle::from_config_struct(config);
931
932 let content = "---\ntitle: FM Title\n---\n\n# Body H1\n\n## Body H2\n\nContent.";
934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
935 let result = rule.check(&ctx).unwrap();
936 assert_eq!(
937 result.len(),
938 1,
939 "Should flag body H2 when level=2 and frontmatter has title"
940 );
941 assert_eq!(result[0].line, 7);
942 }
943
944 #[test]
945 fn test_frontmatter_title_fix_demotes_body_heading() {
946 let config = md025_config::MD025Config {
947 front_matter_title: "title".to_string(),
948 ..Default::default()
949 };
950 let rule = MD025SingleTitle::from_config_struct(config);
951
952 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
953 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
954 let fixed = rule.fix(&ctx).unwrap();
955 assert!(
956 fixed.contains("## Body Heading"),
957 "Fix should demote body H1 to H2 when frontmatter has title, got: {fixed}"
958 );
959 assert!(fixed.contains("---\ntitle: FM Title\n---"));
961 }
962
963 #[test]
964 fn test_frontmatter_title_should_skip_respects_frontmatter() {
965 let rule = MD025SingleTitle::default();
966
967 let content = "---\ntitle: FM Title\n---\n\n# Body Heading\n\nContent.";
969 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
970 assert!(
971 !rule.should_skip(&ctx),
972 "should_skip must return false when frontmatter has title and body has H1"
973 );
974
975 let content = "---\nauthor: Someone\n---\n\n# Body Heading\n\nContent.";
977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 assert!(
979 rule.should_skip(&ctx),
980 "should_skip should return true with no frontmatter title and single H1"
981 );
982 }
983
984 #[test]
985 fn test_section_indicator_whole_word_matching() {
986 let config = md025_config::MD025Config {
988 allow_document_sections: true,
989 ..Default::default()
990 };
991 let rule = MD025SingleTitle::from_config_struct(config);
992
993 let false_positive_cases = vec![
995 "# Main Title\n\n# Understanding Reindex Operations",
996 "# Main Title\n\n# The Summarization Pipeline",
997 "# Main Title\n\n# Data Indexing Strategy",
998 "# Main Title\n\n# Unsupported Browsers",
999 ];
1000
1001 for case in false_positive_cases {
1002 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1003 let result = rule.check(&ctx).unwrap();
1004 assert_eq!(
1005 result.len(),
1006 1,
1007 "Should flag duplicate H1 (not a section indicator): {case}"
1008 );
1009 }
1010
1011 let true_positive_cases = vec![
1013 "# Main Title\n\n# Index",
1014 "# Main Title\n\n# Summary",
1015 "# Main Title\n\n# About",
1016 "# Main Title\n\n# References",
1017 ];
1018
1019 for case in true_positive_cases {
1020 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
1021 let result = rule.check(&ctx).unwrap();
1022 assert!(result.is_empty(), "Should allow section indicator heading: {case}");
1023 }
1024 }
1025}