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