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 is_document_section_heading(&self, heading_text: &str) -> bool {
49 if !self.config.allow_document_sections {
50 return false;
51 }
52
53 let lower_text = heading_text.to_lowercase();
54
55 let section_indicators = [
57 "appendix",
58 "appendices",
59 "reference",
60 "references",
61 "bibliography",
62 "index",
63 "indices",
64 "glossary",
65 "glossaries",
66 "conclusion",
67 "conclusions",
68 "summary",
69 "executive summary",
70 "acknowledgment",
71 "acknowledgments",
72 "acknowledgement",
73 "acknowledgements",
74 "about",
75 "contact",
76 "license",
77 "legal",
78 "changelog",
79 "change log",
80 "history",
81 "faq",
82 "frequently asked questions",
83 "troubleshooting",
84 "support",
85 "installation",
86 "setup",
87 "getting started",
88 "api reference",
89 "api documentation",
90 "examples",
91 "tutorials",
92 "guides",
93 ];
94
95 section_indicators.iter().any(|&indicator| {
97 lower_text.starts_with(indicator) ||
98 lower_text.starts_with(&format!("{indicator}:")) ||
99 lower_text.contains(&format!(" {indicator}")) ||
100 (indicator == "appendix" && (
102 lower_text.matches("appendix").count() == 1 &&
103 (lower_text.contains(" a") || lower_text.contains(" b") ||
104 lower_text.contains(" 1") || lower_text.contains(" 2") ||
105 lower_text.contains(" i") || lower_text.contains(" ii"))
106 ))
107 })
108 }
109
110 fn is_horizontal_rule(line: &str) -> bool {
112 let trimmed = line.trim();
113 HR_DASH.is_match(trimmed)
114 || HR_ASTERISK.is_match(trimmed)
115 || HR_UNDERSCORE.is_match(trimmed)
116 || HR_SPACED_DASH.is_match(trimmed)
117 || HR_SPACED_ASTERISK.is_match(trimmed)
118 || HR_SPACED_UNDERSCORE.is_match(trimmed)
119 }
120
121 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
123 if line_num == 0 || line_num >= ctx.lines.len() {
124 return false;
125 }
126
127 let line = ctx.lines[line_num].content(ctx.content).trim();
128 let prev_line = if line_num > 0 {
129 ctx.lines[line_num - 1].content(ctx.content).trim()
130 } else {
131 ""
132 };
133
134 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
135 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
136 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
137 (is_dash_line || is_equals_line) && prev_line_has_content
138 }
139
140 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
142 if !self.config.allow_with_separators || heading_line == 0 {
143 return false;
144 }
145
146 let search_start = heading_line.saturating_sub(5);
149
150 for line_num in search_start..heading_line {
151 if line_num >= ctx.lines.len() {
152 continue;
153 }
154
155 let line = &ctx.lines[line_num].content(ctx.content);
156 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
157 let has_intermediate_heading =
160 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
161
162 if !has_intermediate_heading {
163 return true;
164 }
165 }
166 }
167
168 false
169 }
170}
171
172impl Rule for MD025SingleTitle {
173 fn name(&self) -> &'static str {
174 "MD025"
175 }
176
177 fn description(&self) -> &'static str {
178 "Multiple top-level headings in the same document"
179 }
180
181 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
182 if ctx.lines.is_empty() {
184 return Ok(Vec::new());
185 }
186
187 let mut warnings = Vec::new();
188
189 let mut _found_title_in_front_matter = false;
191 if !self.config.front_matter_title.is_empty() {
192 let content_lines: Vec<&str> = ctx.content.lines().collect();
194 if content_lines.first().map(|l| l.trim()) == Some("---") {
195 for (idx, line) in content_lines.iter().enumerate().skip(1) {
197 if line.trim() == "---" {
198 let front_matter_content = content_lines[1..idx].join("\n");
200
201 _found_title_in_front_matter = front_matter_content
203 .lines()
204 .any(|line| line.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
205 break;
206 }
207 }
208 }
209 }
210
211 let mut target_level_headings = Vec::new();
213 for (line_num, line_info) in ctx.lines.iter().enumerate() {
214 if let Some(heading) = &line_info.heading
215 && heading.level as usize == self.config.level.as_usize()
216 && heading.is_valid
217 {
219 if line_info.visual_indent >= 4 || line_info.in_code_block {
221 continue;
222 }
223 target_level_headings.push(line_num);
224 }
225 }
226
227 if target_level_headings.len() > 1 {
230 for &line_num in &target_level_headings[1..] {
232 if let Some(heading) = &ctx.lines[line_num].heading {
233 let heading_text = &heading.text;
234
235 let should_allow = self.is_document_section_heading(heading_text)
237 || self.has_separator_before_heading(ctx, line_num);
238
239 if should_allow {
240 continue; }
242
243 let line_content = &ctx.lines[line_num].content(ctx.content);
245 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
246 pos
247 } else {
248 if line_content.trim_start().starts_with('#') {
250 let trimmed = line_content.trim_start();
251 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
252 let after_hashes = &trimmed[hash_count..];
253 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
254 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
255 } else {
256 0 }
258 };
259
260 let (start_line, start_col, end_line, end_col) = calculate_match_range(
261 line_num + 1, line_content,
263 text_start_in_line,
264 heading_text.len(),
265 );
266
267 warnings.push(LintWarning {
268 rule_name: Some(self.name().to_string()),
269 message: format!(
270 "Multiple top-level headings (level {}) in the same document",
271 self.config.level.as_usize()
272 ),
273 line: start_line,
274 column: start_col,
275 end_line,
276 end_column: end_col,
277 severity: Severity::Error,
278 fix: Some(Fix {
279 range: ctx.line_index.line_content_range(line_num + 1),
280 replacement: {
281 let leading_spaces = line_content.len() - line_content.trim_start().len();
282 let indentation = " ".repeat(leading_spaces);
283 let raw = &heading.raw_text;
285 if raw.is_empty() {
286 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
287 } else {
288 format!(
289 "{}{} {}",
290 indentation,
291 "#".repeat(self.config.level.as_usize() + 1),
292 raw
293 )
294 }
295 },
296 }),
297 });
298 }
299 }
300 }
301
302 Ok(warnings)
303 }
304
305 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
306 let mut fixed_lines = Vec::new();
307 let mut found_first = false;
308 let mut skip_next = false;
309
310 for (line_num, line_info) in ctx.lines.iter().enumerate() {
311 if skip_next {
312 skip_next = false;
313 continue;
314 }
315
316 if let Some(heading) = &line_info.heading {
317 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
318 if !found_first {
319 found_first = true;
320 fixed_lines.push(line_info.content(ctx.content).to_string());
322
323 if matches!(
325 heading.style,
326 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
327 ) && line_num + 1 < ctx.lines.len()
328 {
329 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
330 skip_next = true;
331 }
332 } else {
333 let should_allow = self.is_document_section_heading(&heading.text)
335 || self.has_separator_before_heading(ctx, line_num);
336
337 if should_allow {
338 fixed_lines.push(line_info.content(ctx.content).to_string());
340
341 if matches!(
343 heading.style,
344 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
345 ) && line_num + 1 < ctx.lines.len()
346 {
347 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
348 skip_next = true;
349 }
350 } else {
351 let style = match heading.style {
353 crate::lint_context::HeadingStyle::ATX => {
354 if heading.has_closing_sequence {
355 crate::rules::heading_utils::HeadingStyle::AtxClosed
356 } else {
357 crate::rules::heading_utils::HeadingStyle::Atx
358 }
359 }
360 crate::lint_context::HeadingStyle::Setext1 => {
361 if self.config.level.as_usize() == 1 {
363 crate::rules::heading_utils::HeadingStyle::Setext2
364 } else {
365 crate::rules::heading_utils::HeadingStyle::Atx
367 }
368 }
369 crate::lint_context::HeadingStyle::Setext2 => {
370 crate::rules::heading_utils::HeadingStyle::Atx
372 }
373 };
374
375 let replacement = if heading.text.is_empty() {
376 match style {
378 crate::rules::heading_utils::HeadingStyle::Atx
379 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
380 "#".repeat(self.config.level.as_usize() + 1)
381 }
382 crate::rules::heading_utils::HeadingStyle::AtxClosed
383 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
384 format!(
385 "{} {}",
386 "#".repeat(self.config.level.as_usize() + 1),
387 "#".repeat(self.config.level.as_usize() + 1)
388 )
389 }
390 crate::rules::heading_utils::HeadingStyle::Setext1
391 | crate::rules::heading_utils::HeadingStyle::Setext2
392 | crate::rules::heading_utils::HeadingStyle::Consistent => {
393 "#".repeat(self.config.level.as_usize() + 1)
395 }
396 }
397 } else {
398 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
399 &heading.raw_text,
400 (self.config.level.as_usize() + 1) as u32,
401 style,
402 )
403 };
404
405 let line = line_info.content(ctx.content);
407 let original_indent = &line[..line_info.indent];
408 fixed_lines.push(format!("{original_indent}{replacement}"));
409
410 if matches!(
412 heading.style,
413 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
414 ) && line_num + 1 < ctx.lines.len()
415 {
416 skip_next = true;
417 }
418 }
419 }
420 } else {
421 fixed_lines.push(line_info.content(ctx.content).to_string());
423
424 if matches!(
426 heading.style,
427 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
428 ) && line_num + 1 < ctx.lines.len()
429 {
430 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
431 skip_next = true;
432 }
433 }
434 } else {
435 fixed_lines.push(line_info.content(ctx.content).to_string());
437 }
438 }
439
440 let result = fixed_lines.join("\n");
441 if ctx.content.ends_with('\n') {
442 Ok(result + "\n")
443 } else {
444 Ok(result)
445 }
446 }
447
448 fn category(&self) -> RuleCategory {
450 RuleCategory::Heading
451 }
452
453 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
455 if ctx.content.is_empty() {
457 return true;
458 }
459
460 if !ctx.likely_has_headings() {
462 return true;
463 }
464
465 let mut target_level_count = 0;
467 for line_info in &ctx.lines {
468 if let Some(heading) = &line_info.heading
469 && heading.level as usize == self.config.level.as_usize()
470 {
471 if line_info.visual_indent >= 4 || line_info.in_code_block || line_info.in_pymdown_block {
473 continue;
474 }
475 target_level_count += 1;
476
477 if target_level_count > 1 {
480 return false;
481 }
482 }
483 }
484
485 target_level_count <= 1
487 }
488
489 fn as_any(&self) -> &dyn std::any::Any {
490 self
491 }
492
493 fn default_config_section(&self) -> Option<(String, toml::Value)> {
494 let json_value = serde_json::to_value(&self.config).ok()?;
495 Some((
496 self.name().to_string(),
497 crate::rule_config_serde::json_to_toml_value(&json_value)?,
498 ))
499 }
500
501 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
502 where
503 Self: Sized,
504 {
505 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
506 Box::new(Self::from_config_struct(rule_config))
507 }
508}
509
510#[cfg(test)]
511mod tests {
512 use super::*;
513
514 #[test]
515 fn test_with_cached_headings() {
516 let rule = MD025SingleTitle::default();
517
518 let content = "# Title\n\n## Section 1\n\n## Section 2";
520 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
521 let result = rule.check(&ctx).unwrap();
522 assert!(result.is_empty());
523
524 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
526 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
527 let result = rule.check(&ctx).unwrap();
528 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
530
531 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
533 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
534 let result = rule.check(&ctx).unwrap();
535 assert!(result.is_empty(), "Should not flag a single title after front matter");
536 }
537
538 #[test]
539 fn test_allow_document_sections() {
540 let config = md025_config::MD025Config {
542 allow_document_sections: true,
543 ..Default::default()
544 };
545 let rule = MD025SingleTitle::from_config_struct(config);
546
547 let valid_cases = vec![
549 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
550 "# Introduction\n\nContent here\n\n# References\n\nRef content",
551 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
552 "# Manual\n\nContent\n\n# Index\n\nIndex content",
553 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
554 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
555 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
556 ];
557
558 for case in valid_cases {
559 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
560 let result = rule.check(&ctx).unwrap();
561 assert!(result.is_empty(), "Should not flag document sections in: {case}");
562 }
563
564 let invalid_cases = vec![
566 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
567 "# First\n\nContent\n\n# Second Title\n\nMore content",
568 ];
569
570 for case in invalid_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 flag non-section headings in: {case}");
574 }
575 }
576
577 #[test]
578 fn test_strict_mode() {
579 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
583 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
584 let result = rule.check(&ctx).unwrap();
585 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
586 }
587
588 #[test]
589 fn test_bounds_checking_bug() {
590 let rule = MD025SingleTitle::default();
593
594 let content = "# First\n#";
596 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
597
598 let result = rule.check(&ctx);
600 assert!(result.is_ok());
601
602 let fix_result = rule.fix(&ctx);
604 assert!(fix_result.is_ok());
605 }
606
607 #[test]
608 fn test_bounds_checking_edge_case() {
609 let rule = MD025SingleTitle::default();
612
613 let content = "# First Title\n#";
617 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
618
619 let result = rule.check(&ctx);
621 assert!(result.is_ok());
622
623 if let Ok(warnings) = result
624 && !warnings.is_empty()
625 {
626 let fix_result = rule.fix(&ctx);
628 assert!(fix_result.is_ok());
629
630 if let Ok(fixed_content) = fix_result {
632 assert!(!fixed_content.is_empty());
633 assert!(fixed_content.contains("##"));
635 }
636 }
637 }
638
639 #[test]
640 fn test_horizontal_rule_separators() {
641 let config = md025_config::MD025Config {
643 allow_with_separators: true,
644 ..Default::default()
645 };
646 let rule = MD025SingleTitle::from_config_struct(config);
647
648 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.";
650 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
651 let result = rule.check(&ctx).unwrap();
652 assert!(
653 result.is_empty(),
654 "Should not flag headings separated by horizontal rules"
655 );
656
657 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.";
659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
660 let result = rule.check(&ctx).unwrap();
661 assert_eq!(result.len(), 1, "Should flag the heading without separator");
662 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
666 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
667 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
668 let result = strict_rule.check(&ctx).unwrap();
669 assert_eq!(
670 result.len(),
671 1,
672 "Strict mode should flag all multiple H1s regardless of separators"
673 );
674 }
675
676 #[test]
677 fn test_python_comments_in_code_blocks() {
678 let rule = MD025SingleTitle::default();
679
680 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.";
682 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683 let result = rule.check(&ctx).unwrap();
684 assert!(
685 result.is_empty(),
686 "Should not flag Python comments in code blocks as headings"
687 );
688
689 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
691 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
692 let fixed = rule.fix(&ctx).unwrap();
693 assert!(
694 fixed.contains("# Python comment"),
695 "Fix should preserve Python comments in code blocks"
696 );
697 assert!(
698 fixed.contains("## Second Title"),
699 "Fix should demote the actual second heading"
700 );
701 }
702
703 #[test]
704 fn test_fix_preserves_attribute_lists() {
705 let rule = MD025SingleTitle::strict();
706
707 let content = "# First Title\n\n# Second Title { #custom-id .special }";
709 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
710
711 let warnings = rule.check(&ctx).unwrap();
713 assert_eq!(warnings.len(), 1);
714 let fix = warnings[0].fix.as_ref().expect("Should have a fix");
715 assert!(
716 fix.replacement.contains("{ #custom-id .special }"),
717 "check() fix should preserve attribute list, got: {}",
718 fix.replacement
719 );
720
721 let fixed = rule.fix(&ctx).unwrap();
723 assert!(
724 fixed.contains("## Second Title { #custom-id .special }"),
725 "fix() should demote to H2 while preserving attribute list, got: {fixed}"
726 );
727 }
728}