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().to_string()),
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.likely_has_headings() {
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 default_config_section(&self) -> Option<(String, toml::Value)> {
484 let json_value = serde_json::to_value(&self.config).ok()?;
485 Some((
486 self.name().to_string(),
487 crate::rule_config_serde::json_to_toml_value(&json_value)?,
488 ))
489 }
490
491 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
492 where
493 Self: Sized,
494 {
495 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
496 Box::new(Self::from_config_struct(rule_config))
497 }
498}
499
500#[cfg(test)]
501mod tests {
502 use super::*;
503
504 #[test]
505 fn test_with_cached_headings() {
506 let rule = MD025SingleTitle::default();
507
508 let content = "# Title\n\n## Section 1\n\n## Section 2";
510 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
511 let result = rule.check(&ctx).unwrap();
512 assert!(result.is_empty());
513
514 let content = "# Title 1\n\n## Section 1\n\n# Another Title\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_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
520
521 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
523 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
524 let result = rule.check(&ctx).unwrap();
525 assert!(result.is_empty(), "Should not flag a single title after front matter");
526 }
527
528 #[test]
529 fn test_allow_document_sections() {
530 let config = md025_config::MD025Config {
532 allow_document_sections: true,
533 ..Default::default()
534 };
535 let rule = MD025SingleTitle::from_config_struct(config);
536
537 let valid_cases = vec![
539 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
540 "# Introduction\n\nContent here\n\n# References\n\nRef content",
541 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
542 "# Manual\n\nContent\n\n# Index\n\nIndex content",
543 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
544 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
545 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
546 ];
547
548 for case in valid_cases {
549 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
550 let result = rule.check(&ctx).unwrap();
551 assert!(result.is_empty(), "Should not flag document sections in: {case}");
552 }
553
554 let invalid_cases = vec![
556 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
557 "# First\n\nContent\n\n# Second Title\n\nMore content",
558 ];
559
560 for case in invalid_cases {
561 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard);
562 let result = rule.check(&ctx).unwrap();
563 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
564 }
565 }
566
567 #[test]
568 fn test_strict_mode() {
569 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
573 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
574 let result = rule.check(&ctx).unwrap();
575 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
576 }
577
578 #[test]
579 fn test_bounds_checking_bug() {
580 let rule = MD025SingleTitle::default();
583
584 let content = "# First\n#";
586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
587
588 let result = rule.check(&ctx);
590 assert!(result.is_ok());
591
592 let fix_result = rule.fix(&ctx);
594 assert!(fix_result.is_ok());
595 }
596
597 #[test]
598 fn test_bounds_checking_edge_case() {
599 let rule = MD025SingleTitle::default();
602
603 let content = "# First Title\n#";
607 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
608
609 let result = rule.check(&ctx);
611 assert!(result.is_ok());
612
613 if let Ok(warnings) = result
614 && !warnings.is_empty()
615 {
616 let fix_result = rule.fix(&ctx);
618 assert!(fix_result.is_ok());
619
620 if let Ok(fixed_content) = fix_result {
622 assert!(!fixed_content.is_empty());
623 assert!(fixed_content.contains("##"));
625 }
626 }
627 }
628
629 #[test]
630 fn test_horizontal_rule_separators() {
631 let config = md025_config::MD025Config {
633 allow_with_separators: true,
634 ..Default::default()
635 };
636 let rule = MD025SingleTitle::from_config_struct(config);
637
638 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.";
640 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
641 let result = rule.check(&ctx).unwrap();
642 assert!(
643 result.is_empty(),
644 "Should not flag headings separated by horizontal rules"
645 );
646
647 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.";
649 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
650 let result = rule.check(&ctx).unwrap();
651 assert_eq!(result.len(), 1, "Should flag the heading without separator");
652 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
656 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
657 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
658 let result = strict_rule.check(&ctx).unwrap();
659 assert_eq!(
660 result.len(),
661 1,
662 "Strict mode should flag all multiple H1s regardless of separators"
663 );
664 }
665
666 #[test]
667 fn test_python_comments_in_code_blocks() {
668 let rule = MD025SingleTitle::default();
669
670 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.";
672 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
673 let result = rule.check(&ctx).unwrap();
674 assert!(
675 result.is_empty(),
676 "Should not flag Python comments in code blocks as headings"
677 );
678
679 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
681 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard);
682 let fixed = rule.fix(&ctx).unwrap();
683 assert!(
684 fixed.contains("# Python comment"),
685 "Fix should preserve Python comments in code blocks"
686 );
687 assert!(
688 fixed.contains("## Second Title"),
689 "Fix should demote the actual second heading"
690 );
691 }
692}