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 {
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(ctx.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().to_string()),
267 message: format!(
268 "Multiple top-level headings (level {}) in the same document",
269 self.config.level.as_usize()
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: ctx.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.as_usize() + 1))
283 } else {
284 format!(
285 "{}{} {}",
286 indentation,
287 "#".repeat(self.config.level.as_usize() + 1),
288 heading_text
289 )
290 }
291 },
292 }),
293 });
294 }
295 }
296 }
297
298 Ok(warnings)
299 }
300
301 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
302 let mut fixed_lines = Vec::new();
303 let mut found_first = false;
304 let mut skip_next = false;
305
306 for (line_num, line_info) in ctx.lines.iter().enumerate() {
307 if skip_next {
308 skip_next = false;
309 continue;
310 }
311
312 if let Some(heading) = &line_info.heading {
313 if heading.level as usize == self.config.level.as_usize() && !line_info.in_code_block {
314 if !found_first {
315 found_first = true;
316 fixed_lines.push(line_info.content(ctx.content).to_string());
318
319 if matches!(
321 heading.style,
322 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
323 ) && line_num + 1 < ctx.lines.len()
324 {
325 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
326 skip_next = true;
327 }
328 } else {
329 let should_allow = self.is_document_section_heading(&heading.text)
331 || self.has_separator_before_heading(ctx, line_num);
332
333 if should_allow {
334 fixed_lines.push(line_info.content(ctx.content).to_string());
336
337 if matches!(
339 heading.style,
340 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
341 ) && line_num + 1 < ctx.lines.len()
342 {
343 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
344 skip_next = true;
345 }
346 } else {
347 let style = match heading.style {
349 crate::lint_context::HeadingStyle::ATX => {
350 if heading.has_closing_sequence {
351 crate::rules::heading_utils::HeadingStyle::AtxClosed
352 } else {
353 crate::rules::heading_utils::HeadingStyle::Atx
354 }
355 }
356 crate::lint_context::HeadingStyle::Setext1 => {
357 if self.config.level.as_usize() == 1 {
359 crate::rules::heading_utils::HeadingStyle::Setext2
360 } else {
361 crate::rules::heading_utils::HeadingStyle::Atx
363 }
364 }
365 crate::lint_context::HeadingStyle::Setext2 => {
366 crate::rules::heading_utils::HeadingStyle::Atx
368 }
369 };
370
371 let replacement = if heading.text.is_empty() {
372 match style {
374 crate::rules::heading_utils::HeadingStyle::Atx
375 | crate::rules::heading_utils::HeadingStyle::SetextWithAtx => {
376 "#".repeat(self.config.level.as_usize() + 1)
377 }
378 crate::rules::heading_utils::HeadingStyle::AtxClosed
379 | crate::rules::heading_utils::HeadingStyle::SetextWithAtxClosed => {
380 format!(
381 "{} {}",
382 "#".repeat(self.config.level.as_usize() + 1),
383 "#".repeat(self.config.level.as_usize() + 1)
384 )
385 }
386 crate::rules::heading_utils::HeadingStyle::Setext1
387 | crate::rules::heading_utils::HeadingStyle::Setext2
388 | crate::rules::heading_utils::HeadingStyle::Consistent => {
389 "#".repeat(self.config.level.as_usize() + 1)
391 }
392 }
393 } else {
394 crate::rules::heading_utils::HeadingUtils::convert_heading_style(
395 &heading.text,
396 (self.config.level.as_usize() + 1) as u32,
397 style,
398 )
399 };
400
401 let indentation = " ".repeat(line_info.indent);
403 fixed_lines.push(format!("{indentation}{replacement}"));
404
405 if matches!(
407 heading.style,
408 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
409 ) && line_num + 1 < ctx.lines.len()
410 {
411 skip_next = true;
412 }
413 }
414 }
415 } else {
416 fixed_lines.push(line_info.content(ctx.content).to_string());
418
419 if matches!(
421 heading.style,
422 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
423 ) && line_num + 1 < ctx.lines.len()
424 {
425 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
426 skip_next = true;
427 }
428 }
429 } else {
430 fixed_lines.push(line_info.content(ctx.content).to_string());
432 }
433 }
434
435 let result = fixed_lines.join("\n");
436 if ctx.content.ends_with('\n') {
437 Ok(result + "\n")
438 } else {
439 Ok(result)
440 }
441 }
442
443 fn category(&self) -> RuleCategory {
445 RuleCategory::Heading
446 }
447
448 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
450 if ctx.content.is_empty() {
452 return true;
453 }
454
455 if !ctx.likely_has_headings() {
457 return true;
458 }
459
460 let mut target_level_count = 0;
462 for line_info in &ctx.lines {
463 if let Some(heading) = &line_info.heading
464 && heading.level as usize == self.config.level.as_usize()
465 {
466 if line_info.indent >= 4 || line_info.in_code_block {
468 continue;
469 }
470 target_level_count += 1;
471
472 if target_level_count > 1 {
475 return false;
476 }
477 }
478 }
479
480 target_level_count <= 1
482 }
483
484 fn as_any(&self) -> &dyn std::any::Any {
485 self
486 }
487
488 fn default_config_section(&self) -> Option<(String, toml::Value)> {
489 let json_value = serde_json::to_value(&self.config).ok()?;
490 Some((
491 self.name().to_string(),
492 crate::rule_config_serde::json_to_toml_value(&json_value)?,
493 ))
494 }
495
496 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
497 where
498 Self: Sized,
499 {
500 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
501 Box::new(Self::from_config_struct(rule_config))
502 }
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508
509 #[test]
510 fn test_with_cached_headings() {
511 let rule = MD025SingleTitle::default();
512
513 let content = "# Title\n\n## Section 1\n\n## Section 2";
515 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
516 let result = rule.check(&ctx).unwrap();
517 assert!(result.is_empty());
518
519 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
521 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
522 let result = rule.check(&ctx).unwrap();
523 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
525
526 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
528 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
529 let result = rule.check(&ctx).unwrap();
530 assert!(result.is_empty(), "Should not flag a single title after front matter");
531 }
532
533 #[test]
534 fn test_allow_document_sections() {
535 let config = md025_config::MD025Config {
537 allow_document_sections: true,
538 ..Default::default()
539 };
540 let rule = MD025SingleTitle::from_config_struct(config);
541
542 let valid_cases = vec![
544 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
545 "# Introduction\n\nContent here\n\n# References\n\nRef content",
546 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
547 "# Manual\n\nContent\n\n# Index\n\nIndex content",
548 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
549 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
550 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
551 ];
552
553 for case in valid_cases {
554 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
555 let result = rule.check(&ctx).unwrap();
556 assert!(result.is_empty(), "Should not flag document sections in: {case}");
557 }
558
559 let invalid_cases = vec![
561 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
562 "# First\n\nContent\n\n# Second Title\n\nMore content",
563 ];
564
565 for case in invalid_cases {
566 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
567 let result = rule.check(&ctx).unwrap();
568 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
569 }
570 }
571
572 #[test]
573 fn test_strict_mode() {
574 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
578 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
579 let result = rule.check(&ctx).unwrap();
580 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
581 }
582
583 #[test]
584 fn test_bounds_checking_bug() {
585 let rule = MD025SingleTitle::default();
588
589 let content = "# First\n#";
591 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
592
593 let result = rule.check(&ctx);
595 assert!(result.is_ok());
596
597 let fix_result = rule.fix(&ctx);
599 assert!(fix_result.is_ok());
600 }
601
602 #[test]
603 fn test_bounds_checking_edge_case() {
604 let rule = MD025SingleTitle::default();
607
608 let content = "# First Title\n#";
612 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
613
614 let result = rule.check(&ctx);
616 assert!(result.is_ok());
617
618 if let Ok(warnings) = result
619 && !warnings.is_empty()
620 {
621 let fix_result = rule.fix(&ctx);
623 assert!(fix_result.is_ok());
624
625 if let Ok(fixed_content) = fix_result {
627 assert!(!fixed_content.is_empty());
628 assert!(fixed_content.contains("##"));
630 }
631 }
632 }
633
634 #[test]
635 fn test_horizontal_rule_separators() {
636 let config = md025_config::MD025Config {
638 allow_with_separators: true,
639 ..Default::default()
640 };
641 let rule = MD025SingleTitle::from_config_struct(config);
642
643 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.";
645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
646 let result = rule.check(&ctx).unwrap();
647 assert!(
648 result.is_empty(),
649 "Should not flag headings separated by horizontal rules"
650 );
651
652 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.";
654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
655 let result = rule.check(&ctx).unwrap();
656 assert_eq!(result.len(), 1, "Should flag the heading without separator");
657 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
661 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
662 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
663 let result = strict_rule.check(&ctx).unwrap();
664 assert_eq!(
665 result.len(),
666 1,
667 "Strict mode should flag all multiple H1s regardless of separators"
668 );
669 }
670
671 #[test]
672 fn test_python_comments_in_code_blocks() {
673 let rule = MD025SingleTitle::default();
674
675 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.";
677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
678 let result = rule.check(&ctx).unwrap();
679 assert!(
680 result.is_empty(),
681 "Should not flag Python comments in code blocks as headings"
682 );
683
684 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
687 let fixed = rule.fix(&ctx).unwrap();
688 assert!(
689 fixed.contains("# Python comment"),
690 "Fix should preserve Python comments in code blocks"
691 );
692 assert!(
693 fixed.contains("## Second Title"),
694 "Fix should demote the actual second heading"
695 );
696 }
697}