1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
5use crate::utils::range_utils::{LineIndex, calculate_match_range};
6use crate::utils::regex_cache::{
7 HR_ASTERISK, HR_DASH, HR_SPACED_ASTERISK, HR_SPACED_DASH, HR_SPACED_UNDERSCORE, HR_UNDERSCORE,
8};
9use toml;
10
11mod md025_config;
12use md025_config::MD025Config;
13
14#[derive(Clone, Default)]
15pub struct MD025SingleTitle {
16 config: MD025Config,
17}
18
19impl MD025SingleTitle {
20 pub fn new(level: usize, front_matter_title: &str) -> Self {
21 Self {
22 config: MD025Config {
23 level,
24 front_matter_title: front_matter_title.to_string(),
25 allow_document_sections: true,
26 allow_with_separators: true,
27 },
28 }
29 }
30
31 pub fn strict() -> Self {
32 Self {
33 config: MD025Config {
34 level: 1,
35 front_matter_title: "title".to_string(),
36 allow_document_sections: false,
37 allow_with_separators: false,
38 },
39 }
40 }
41
42 pub fn from_config_struct(config: MD025Config) -> Self {
43 Self { config }
44 }
45
46 fn is_document_section_heading(&self, heading_text: &str) -> bool {
48 if !self.config.allow_document_sections {
49 return false;
50 }
51
52 let lower_text = heading_text.to_lowercase();
53
54 let section_indicators = [
56 "appendix",
57 "appendices",
58 "reference",
59 "references",
60 "bibliography",
61 "index",
62 "indices",
63 "glossary",
64 "glossaries",
65 "conclusion",
66 "conclusions",
67 "summary",
68 "executive summary",
69 "acknowledgment",
70 "acknowledgments",
71 "acknowledgement",
72 "acknowledgements",
73 "about",
74 "contact",
75 "license",
76 "legal",
77 "changelog",
78 "change log",
79 "history",
80 "faq",
81 "frequently asked questions",
82 "troubleshooting",
83 "support",
84 "installation",
85 "setup",
86 "getting started",
87 "api reference",
88 "api documentation",
89 "examples",
90 "tutorials",
91 "guides",
92 ];
93
94 section_indicators.iter().any(|&indicator| {
96 lower_text.starts_with(indicator) ||
97 lower_text.starts_with(&format!("{indicator}:")) ||
98 lower_text.contains(&format!(" {indicator}")) ||
99 (indicator == "appendix" && (
101 lower_text.matches("appendix").count() == 1 &&
102 (lower_text.contains(" a") || lower_text.contains(" b") ||
103 lower_text.contains(" 1") || lower_text.contains(" 2") ||
104 lower_text.contains(" i") || lower_text.contains(" ii"))
105 ))
106 })
107 }
108
109 fn is_horizontal_rule(line: &str) -> bool {
111 let trimmed = line.trim();
112 HR_DASH.is_match(trimmed)
113 || HR_ASTERISK.is_match(trimmed)
114 || HR_UNDERSCORE.is_match(trimmed)
115 || HR_SPACED_DASH.is_match(trimmed)
116 || HR_SPACED_ASTERISK.is_match(trimmed)
117 || HR_SPACED_UNDERSCORE.is_match(trimmed)
118 }
119
120 fn is_potential_setext_heading(ctx: &crate::lint_context::LintContext, line_num: usize) -> bool {
122 if line_num == 0 || line_num >= ctx.lines.len() {
123 return false;
124 }
125
126 let line = ctx.lines[line_num].content.trim();
127 let prev_line = if line_num > 0 {
128 ctx.lines[line_num - 1].content.trim()
129 } else {
130 ""
131 };
132
133 let is_dash_line = !line.is_empty() && line.chars().all(|c| c == '-');
134 let is_equals_line = !line.is_empty() && line.chars().all(|c| c == '=');
135 let prev_line_has_content = !prev_line.is_empty() && !Self::is_horizontal_rule(prev_line);
136 (is_dash_line || is_equals_line) && prev_line_has_content
137 }
138
139 fn has_separator_before_heading(&self, ctx: &crate::lint_context::LintContext, heading_line: usize) -> bool {
141 if !self.config.allow_with_separators || heading_line == 0 {
142 return false;
143 }
144
145 let search_start = heading_line.saturating_sub(5);
148
149 for line_num in search_start..heading_line {
150 if line_num >= ctx.lines.len() {
151 continue;
152 }
153
154 let line = &ctx.lines[line_num].content;
155 if Self::is_horizontal_rule(line) && !Self::is_potential_setext_heading(ctx, line_num) {
156 let has_intermediate_heading =
159 ((line_num + 1)..heading_line).any(|idx| idx < ctx.lines.len() && ctx.lines[idx].heading.is_some());
160
161 if !has_intermediate_heading {
162 return true;
163 }
164 }
165 }
166
167 false
168 }
169}
170
171impl Rule for MD025SingleTitle {
172 fn name(&self) -> &'static str {
173 "MD025"
174 }
175
176 fn description(&self) -> &'static str {
177 "Multiple top-level headings in the same document"
178 }
179
180 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
181 if ctx.lines.is_empty() {
183 return Ok(Vec::new());
184 }
185
186 let line_index = LineIndex::new(ctx.content.to_string());
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
216 {
217 if line_info.indent >= 4 || line_info.in_code_block {
219 continue;
220 }
221 target_level_headings.push(line_num);
222 }
223 }
224
225 if target_level_headings.len() > 1 {
228 for &line_num in &target_level_headings[1..] {
230 if let Some(heading) = &ctx.lines[line_num].heading {
231 let heading_text = &heading.text;
232
233 let should_allow = self.is_document_section_heading(heading_text)
235 || self.has_separator_before_heading(ctx, line_num);
236
237 if should_allow {
238 continue; }
240
241 let line_content = &ctx.lines[line_num].content;
243 let text_start_in_line = if let Some(pos) = line_content.find(heading_text) {
244 pos
245 } else {
246 if line_content.trim_start().starts_with('#') {
248 let trimmed = line_content.trim_start();
249 let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
250 let after_hashes = &trimmed[hash_count..];
251 let text_start_in_trimmed = after_hashes.find(heading_text).unwrap_or(0);
252 (line_content.len() - trimmed.len()) + hash_count + text_start_in_trimmed
253 } else {
254 0 }
256 };
257
258 let (start_line, start_col, end_line, end_col) = calculate_match_range(
259 line_num + 1, line_content,
261 text_start_in_line,
262 heading_text.len(),
263 );
264
265 warnings.push(LintWarning {
266 rule_name: Some(self.name()),
267 message: format!(
268 "Multiple top-level headings (level {}) in the same document",
269 self.config.level
270 ),
271 line: start_line,
272 column: start_col,
273 end_line,
274 end_column: end_col,
275 severity: Severity::Warning,
276 fix: Some(Fix {
277 range: line_index.line_content_range(line_num + 1),
278 replacement: {
279 let leading_spaces = line_content.len() - line_content.trim_start().len();
280 let indentation = " ".repeat(leading_spaces);
281 if heading_text.is_empty() {
282 format!("{}{}", indentation, "#".repeat(self.config.level + 1))
283 } else {
284 format!("{}{} {}", indentation, "#".repeat(self.config.level + 1), heading_text)
285 }
286 },
287 }),
288 });
289 }
290 }
291 }
292
293 Ok(warnings)
294 }
295
296 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
297 let mut fixed_lines = Vec::new();
298 let mut found_first = false;
299 let mut skip_next = false;
300
301 for (line_num, line_info) in ctx.lines.iter().enumerate() {
302 if skip_next {
303 skip_next = false;
304 continue;
305 }
306
307 if let Some(heading) = &line_info.heading {
308 if heading.level as usize == self.config.level && !line_info.in_code_block {
309 if !found_first {
310 found_first = true;
311 fixed_lines.push(line_info.content.clone());
313
314 if matches!(
316 heading.style,
317 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
318 ) && line_num + 1 < ctx.lines.len()
319 {
320 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
321 skip_next = true;
322 }
323 } else {
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 fixed_lines.push(line_info.content.clone());
331
332 if matches!(
334 heading.style,
335 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
336 ) && line_num + 1 < ctx.lines.len()
337 {
338 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
339 skip_next = true;
340 }
341 } else {
342 let style = match heading.style {
344 crate::lint_context::HeadingStyle::ATX => {
345 if heading.has_closing_sequence {
346 crate::rules::heading_utils::HeadingStyle::AtxClosed
347 } else {
348 crate::rules::heading_utils::HeadingStyle::Atx
349 }
350 }
351 crate::lint_context::HeadingStyle::Setext1 => {
352 if self.config.level == 1 {
354 crate::rules::heading_utils::HeadingStyle::Setext2
355 } else {
356 crate::rules::heading_utils::HeadingStyle::Atx
358 }
359 }
360 crate::lint_context::HeadingStyle::Setext2 => {
361 crate::rules::heading_utils::HeadingStyle::Atx
363 }
364 };
365
366 let replacement = if heading.text.is_empty() {
367 match style {
369 crate::rules::heading_utils::HeadingStyle::Atx
370 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
371 "#".repeat(self.config.level + 1)
372 }
373 crate::rules::heading_utils::HeadingStyle::AtxClosed
374 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
375 format!(
376 "{} {}",
377 "#".repeat(self.config.level + 1),
378 "#".repeat(self.config.level + 1)
379 )
380 }
381 crate::rules::heading_utils::HeadingStyle::Setext1
382 | crate::rules::heading_utils::HeadingStyle::Setext2
383 | crate::rules::heading_utils::HeadingStyle::Consistent => {
384 "#".repeat(self.config.level + 1)
386 }
387 }
388 } else {
389 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
390 &heading.text,
391 (self.config.level + 1) as u32,
392 style,
393 )
394 };
395
396 let indentation = " ".repeat(line_info.indent);
398 fixed_lines.push(format!("{indentation}{replacement}"));
399
400 if matches!(
402 heading.style,
403 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
404 ) && line_num + 1 < ctx.lines.len()
405 {
406 skip_next = true;
407 }
408 }
409 }
410 } else {
411 fixed_lines.push(line_info.content.clone());
413
414 if matches!(
416 heading.style,
417 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
418 ) && line_num + 1 < ctx.lines.len()
419 {
420 fixed_lines.push(ctx.lines[line_num + 1].content.clone());
421 skip_next = true;
422 }
423 }
424 } else {
425 fixed_lines.push(line_info.content.clone());
427 }
428 }
429
430 let result = fixed_lines.join("\n");
431 if ctx.content.ends_with('\n') {
432 Ok(result + "\n")
433 } else {
434 Ok(result)
435 }
436 }
437
438 fn category(&self) -> RuleCategory {
440 RuleCategory::Heading
441 }
442
443 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
445 if ctx.content.is_empty() {
447 return true;
448 }
449
450 if !ctx.content.contains('#') && !ctx.content.contains('=') && !ctx.content.contains('-') {
452 return true;
453 }
454
455 let mut target_level_count = 0;
457 for line_info in &ctx.lines {
458 if let Some(heading) = &line_info.heading
459 && heading.level as usize == self.config.level
460 {
461 if line_info.indent >= 4 || line_info.in_code_block {
463 continue;
464 }
465 target_level_count += 1;
466
467 if target_level_count > 1 {
470 return false;
471 }
472 }
473 }
474
475 target_level_count <= 1
477 }
478
479 fn as_any(&self) -> &dyn std::any::Any {
480 self
481 }
482
483 fn as_maybe_document_structure(&self) -> Option<&dyn crate::rule::MaybeDocumentStructure> {
484 None
485 }
486
487 fn default_config_section(&self) -> Option<(String, toml::Value)> {
488 let json_value = serde_json::to_value(&self.config).ok()?;
489 Some((
490 self.name().to_string(),
491 crate::rule_config_serde::json_to_toml_value(&json_value)?,
492 ))
493 }
494
495 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
496 where
497 Self: Sized,
498 {
499 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
500 Box::new(Self::from_config_struct(rule_config))
501 }
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507
508 #[test]
509 fn test_with_cached_headings() {
510 let rule = MD025SingleTitle::default();
511
512 let content = "# Title\n\n## Section 1\n\n## Section 2";
514 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
515 let result = rule.check(&ctx).unwrap();
516 assert!(result.is_empty());
517
518 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
520 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
521 let result = rule.check(&ctx).unwrap();
522 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
524
525 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
527 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
528 let result = rule.check(&ctx).unwrap();
529 assert!(result.is_empty(), "Should not flag a single title after front matter");
530 }
531
532 #[test]
533 fn test_allow_document_sections() {
534 let rule = MD025SingleTitle::default(); let valid_cases = vec![
538 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
539 "# Introduction\n\nContent here\n\n# References\n\nRef content",
540 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
541 "# Manual\n\nContent\n\n# Index\n\nIndex content",
542 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
543 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
544 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
545 ];
546
547 for case in valid_cases {
548 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
549 let result = rule.check(&ctx).unwrap();
550 assert!(result.is_empty(), "Should not flag document sections in: {case}");
551 }
552
553 let invalid_cases = vec![
555 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
556 "# First\n\nContent\n\n# Second Title\n\nMore content",
557 ];
558
559 for case in invalid_cases {
560 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
561 let result = rule.check(&ctx).unwrap();
562 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
563 }
564 }
565
566 #[test]
567 fn test_strict_mode() {
568 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
572 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
573 let result = rule.check(&ctx).unwrap();
574 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
575 }
576
577 #[test]
578 fn test_bounds_checking_bug() {
579 let rule = MD025SingleTitle::default();
582
583 let content = "# First\n#";
585 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
586
587 let result = rule.check(&ctx);
589 assert!(result.is_ok());
590
591 let fix_result = rule.fix(&ctx);
593 assert!(fix_result.is_ok());
594 }
595
596 #[test]
597 fn test_bounds_checking_edge_case() {
598 let rule = MD025SingleTitle::default();
601
602 let content = "# First Title\n#";
606 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
607
608 let result = rule.check(&ctx);
610 assert!(result.is_ok());
611
612 if let Ok(warnings) = result
613 && !warnings.is_empty()
614 {
615 let fix_result = rule.fix(&ctx);
617 assert!(fix_result.is_ok());
618
619 if let Ok(fixed_content) = fix_result {
621 assert!(!fixed_content.is_empty());
622 assert!(fixed_content.contains("##"));
624 }
625 }
626 }
627
628 #[test]
629 fn test_horizontal_rule_separators() {
630 let rule = MD025SingleTitle::default(); 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.";
634 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
635 let result = rule.check(&ctx).unwrap();
636 assert!(
637 result.is_empty(),
638 "Should not flag headings separated by horizontal rules"
639 );
640
641 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.";
643 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
644 let result = rule.check(&ctx).unwrap();
645 assert_eq!(result.len(), 1, "Should flag the heading without separator");
646 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
650 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
651 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
652 let result = strict_rule.check(&ctx).unwrap();
653 assert_eq!(
654 result.len(),
655 1,
656 "Strict mode should flag all multiple H1s regardless of separators"
657 );
658 }
659
660 #[test]
661 fn test_python_comments_in_code_blocks() {
662 let rule = MD025SingleTitle::default();
663
664 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.";
666 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
667 let result = rule.check(&ctx).unwrap();
668 assert!(
669 result.is_empty(),
670 "Should not flag Python comments in code blocks as headings"
671 );
672
673 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
675 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
676 let fixed = rule.fix(&ctx).unwrap();
677 assert!(
678 fixed.contains("# Python comment"),
679 "Fix should preserve Python comments in code blocks"
680 );
681 assert!(
682 fixed.contains("## Second Title"),
683 "Fix should demote the actual second heading"
684 );
685 }
686}