1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::types::HeadingLevel;
6use crate::utils::range_utils::{LineIndex, 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.trim();
128 let prev_line = if line_num > 0 {
129 ctx.lines[line_num - 1].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;
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 line_index = LineIndex::new(ctx.content.to_string());
188 let mut warnings = Vec::new();
189
190 let mut _found_title_in_front_matter = false;
192 if !self.config.front_matter_title.is_empty() {
193 let content_lines: Vec<&str> = ctx.content.lines().collect();
195 if content_lines.first().map(|l| l.trim()) == Some("---") {
196 for (idx, line) in content_lines.iter().enumerate().skip(1) {
198 if line.trim() == "---" {
199 let front_matter_content = content_lines[1..idx].join("\n");
201
202 _found_title_in_front_matter = front_matter_content
204 .lines()
205 .any(|line| line.trim().starts_with(&format!("{}:", self.config.front_matter_title)));
206 break;
207 }
208 }
209 }
210 }
211
212 let mut target_level_headings = Vec::new();
214 for (line_num, line_info) in ctx.lines.iter().enumerate() {
215 if let Some(heading) = &line_info.heading
216 && heading.level as usize == self.config.level.as_usize()
217 {
218 if line_info.indent >= 4 || line_info.in_code_block {
220 continue;
221 }
222 target_level_headings.push(line_num);
223 }
224 }
225
226 if target_level_headings.len() > 1 {
229 for &line_num in &target_level_headings[1..] {
231 if let Some(heading) = &ctx.lines[line_num].heading {
232 let heading_text = &heading.text;
233
234 let should_allow = self.is_document_section_heading(heading_text)
236 || self.has_separator_before_heading(ctx, line_num);
237
238 if should_allow {
239 continue; }
241
242 let line_content = &ctx.lines[line_num].content;
244 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
245 pos
246 } else {
247 if line_content.trim_start().starts_with('#') {
249 let trimmed = line_content.trim_start();
250 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
251 let after_hashes = &trimmed[hash_count..];
252 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
253 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
254 } else {
255 0 }
257 };
258
259 let (start_line, start_col, end_line, end_col) = calculate_match_range(
260 line_num + 1, line_content,
262 text_start_in_line,
263 heading_text.len(),
264 );
265
266 warnings.push(LintWarning {
267 rule_name: Some(self.name().to_string()),
268 message: format!(
269 "Multiple top-level headings (level {}) in the same document",
270 self.config.level.as_usize()
271 ),
272 line: start_line,
273 column: start_col,
274 end_line,
275 end_column: end_col,
276 severity: Severity::Warning,
277 fix: Some(Fix {
278 range: line_index.line_content_range(line_num + 1),
279 replacement: {
280 let leading_spaces = line_content.len() - line_content.trim_start().len();
281 let indentation = " ".repeat(leading_spaces);
282 if heading_text.is_empty() {
283 format!("{}{}", indentation, "#".repeat(self.config.level.as_usize() + 1))
284 } else {
285 format!(
286 "{}{} {}",
287 indentation,
288 "#".repeat(self.config.level.as_usize() + 1),
289 heading_text
290 )
291 }
292 },
293 }),
294 });
295 }
296 }
297 }
298
299 Ok(warnings)
300 }
301
302 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
303 let mut fixed_lines = Vec::new();
304 let mut found_first = false;
305 let mut skip_next = false;
306
307 for (line_num, line_info) in ctx.lines.iter().enumerate() {
308 if skip_next {
309 skip_next = false;
310 continue;
311 }
312
313 if let Some(heading) = &line_info.heading {
314 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
315 if !found_first {
316 found_first = true;
317 fixed_lines.push(line_info.content.clone());
319
320 if matches!(
322 heading.style,
323 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
324 ) && line_num + 1 < ctx.lines.len()
325 {
326 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
327 skip_next = true;
328 }
329 } else {
330 let should_allow = self.is_document_section_heading(&heading.text)
332 || self.has_separator_before_heading(ctx, line_num);
333
334 if should_allow {
335 fixed_lines.push(line_info.content.clone());
337
338 if matches!(
340 heading.style,
341 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
342 ) && line_num + 1 < ctx.lines.len()
343 {
344 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
345 skip_next = true;
346 }
347 } else {
348 let style = match heading.style {
350 crate::lint_context::HeadingStyle::ATX => {
351 if heading.has_closing_sequence {
352 crate::rules::heading_utils::HeadingStyle::AtxClosed
353 } else {
354 crate::rules::heading_utils::HeadingStyle::Atx
355 }
356 }
357 crate::lint_context::HeadingStyle::Setext1 => {
358 if self.config.level.as_usize() == 1 {
360 crate::rules::heading_utils::HeadingStyle::Setext2
361 } else {
362 crate::rules::heading_utils::HeadingStyle::Atx
364 }
365 }
366 crate::lint_context::HeadingStyle::Setext2 => {
367 crate::rules::heading_utils::HeadingStyle::Atx
369 }
370 };
371
372 let replacement = if heading.text.is_empty() {
373 match style {
375 crate::rules::heading_utils::HeadingStyle::Atx
376 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
377 "#".repeat(self.config.level.as_usize() + 1)
378 }
379 crate::rules::heading_utils::HeadingStyle::AtxClosed
380 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
381 format!(
382 "{} {}",
383 "#".repeat(self.config.level.as_usize() + 1),
384 "#".repeat(self.config.level.as_usize() + 1)
385 )
386 }
387 crate::rules::heading_utils::HeadingStyle::Setext1
388 | crate::rules::heading_utils::HeadingStyle::Setext2
389 | crate::rules::heading_utils::HeadingStyle::Consistent => {
390 "#".repeat(self.config.level.as_usize() + 1)
392 }
393 }
394 } else {
395 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
396 &heading.text,
397 (self.config.level.as_usize() + 1) as u32,
398 style,
399 )
400 };
401
402 let indentation = " ".repeat(line_info.indent);
404 fixed_lines.push(format!("{indentation}{replacement}"));
405
406 if matches!(
408 heading.style,
409 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
410 ) && line_num + 1 < ctx.lines.len()
411 {
412 skip_next = true;
413 }
414 }
415 }
416 } else {
417 fixed_lines.push(line_info.content.clone());
419
420 if matches!(
422 heading.style,
423 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
424 ) && line_num + 1 < ctx.lines.len()
425 {
426 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
427 skip_next = true;
428 }
429 }
430 } else {
431 fixed_lines.push(line_info.content.clone());
433 }
434 }
435
436 let result = fixed_lines.join("\n");
437 if ctx.content.ends_with('\n') {
438 Ok(result + "\n")
439 } else {
440 Ok(result)
441 }
442 }
443
444 fn category(&self) -> RuleCategory {
446 RuleCategory::Heading
447 }
448
449 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
451 if ctx.content.is_empty() {
453 return true;
454 }
455
456 if !ctx.likely_has_headings() {
458 return true;
459 }
460
461 let mut target_level_count = 0;
463 for line_info in &ctx.lines {
464 if let Some(heading) = &line_info.heading
465 && heading.level as usize == self.config.level.as_usize()
466 {
467 if line_info.indent >= 4 || line_info.in_code_block {
469 continue;
470 }
471 target_level_count += 1;
472
473 if target_level_count > 1 {
476 return false;
477 }
478 }
479 }
480
481 target_level_count <= 1
483 }
484
485 fn as_any(&self) -> &dyn std::any::Any {
486 self
487 }
488
489 fn default_config_section(&self) -> Option<(String, toml::Value)> {
490 let json_value = serde_json::to_value(&self.config).ok()?;
491 Some((
492 self.name().to_string(),
493 crate::rule_config_serde::json_to_toml_value(&json_value)?,
494 ))
495 }
496
497 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
498 where
499 Self: Sized,
500 {
501 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
502 Box::new(Self::from_config_struct(rule_config))
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509
510 #[test]
511 fn test_with_cached_headings() {
512 let rule = MD025SingleTitle::default();
513
514 let content = "# Title\n\n## Section 1\n\n## Section 2";
516 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
517 let result = rule.check(&ctx).unwrap();
518 assert!(result.is_empty());
519
520 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
522 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
523 let result = rule.check(&ctx).unwrap();
524 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
526
527 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
529 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
530 let result = rule.check(&ctx).unwrap();
531 assert!(result.is_empty(), "Should not flag a single title after front matter");
532 }
533
534 #[test]
535 fn test_allow_document_sections() {
536 let config = md025_config::MD025Config {
538 allow_document_sections: true,
539 ..Default::default()
540 };
541 let rule = MD025SingleTitle::from_config_struct(config);
542
543 let valid_cases = vec![
545 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
546 "# Introduction\n\nContent here\n\n# References\n\nRef content",
547 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
548 "# Manual\n\nContent\n\n# Index\n\nIndex content",
549 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
550 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
551 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
552 ];
553
554 for case in valid_cases {
555 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
556 let result = rule.check(&ctx).unwrap();
557 assert!(result.is_empty(), "Should not flag document sections in: {case}");
558 }
559
560 let invalid_cases = vec![
562 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
563 "# First\n\nContent\n\n# Second Title\n\nMore content",
564 ];
565
566 for case in invalid_cases {
567 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
568 let result = rule.check(&ctx).unwrap();
569 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
570 }
571 }
572
573 #[test]
574 fn test_strict_mode() {
575 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
580 let result = rule.check(&ctx).unwrap();
581 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
582 }
583
584 #[test]
585 fn test_bounds_checking_bug() {
586 let rule = MD025SingleTitle::default();
589
590 let content = "# First\n#";
592 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
593
594 let result = rule.check(&ctx);
596 assert!(result.is_ok());
597
598 let fix_result = rule.fix(&ctx);
600 assert!(fix_result.is_ok());
601 }
602
603 #[test]
604 fn test_bounds_checking_edge_case() {
605 let rule = MD025SingleTitle::default();
608
609 let content = "# First Title\n#";
613 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
614
615 let result = rule.check(&ctx);
617 assert!(result.is_ok());
618
619 if let Ok(warnings) = result
620 && !warnings.is_empty()
621 {
622 let fix_result = rule.fix(&ctx);
624 assert!(fix_result.is_ok());
625
626 if let Ok(fixed_content) = fix_result {
628 assert!(!fixed_content.is_empty());
629 assert!(fixed_content.contains("##"));
631 }
632 }
633 }
634
635 #[test]
636 fn test_horizontal_rule_separators() {
637 let config = md025_config::MD025Config {
639 allow_with_separators: true,
640 ..Default::default()
641 };
642 let rule = MD025SingleTitle::from_config_struct(config);
643
644 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.";
646 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
647 let result = rule.check(&ctx).unwrap();
648 assert!(
649 result.is_empty(),
650 "Should not flag headings separated by horizontal rules"
651 );
652
653 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.";
655 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
656 let result = rule.check(&ctx).unwrap();
657 assert_eq!(result.len(), 1, "Should flag the heading without separator");
658 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
662 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
663 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
664 let result = strict_rule.check(&ctx).unwrap();
665 assert_eq!(
666 result.len(),
667 1,
668 "Strict mode should flag all multiple H1s regardless of separators"
669 );
670 }
671
672 #[test]
673 fn test_python_comments_in_code_blocks() {
674 let rule = MD025SingleTitle::default();
675
676 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.";
678 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
679 let result = rule.check(&ctx).unwrap();
680 assert!(
681 result.is_empty(),
682 "Should not flag Python comments in code blocks as headings"
683 );
684
685 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
687 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
688 let fixed = rule.fix(&ctx).unwrap();
689 assert!(
690 fixed.contains("# Python comment"),
691 "Fix should preserve Python comments in code blocks"
692 );
693 assert!(
694 fixed.contains("## Second Title"),
695 "Fix should demote the actual second heading"
696 );
697 }
698}