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.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 indentation = " ".repeat(line_info.indent);
405 fixed_lines.push(format!("{indentation}{replacement}"));
406
407 if matches!(
409 heading.style,
410 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
411 ) && line_num + 1 < ctx.lines.len()
412 {
413 skip_next = true;
414 }
415 }
416 }
417 } else {
418 fixed_lines.push(line_info.content(ctx.content).to_string());
420
421 if matches!(
423 heading.style,
424 crate::lint_context::HeadingStyle::Setext1 | crate::lint_context::HeadingStyle::Setext2
425 ) && line_num + 1 < ctx.lines.len()
426 {
427 fixed_lines.push(ctx.lines[line_num + 1].content(ctx.content).to_string());
428 skip_next = true;
429 }
430 }
431 } else {
432 fixed_lines.push(line_info.content(ctx.content).to_string());
434 }
435 }
436
437 let result = fixed_lines.join("\n");
438 if ctx.content.ends_with('\n') {
439 Ok(result + "\n")
440 } else {
441 Ok(result)
442 }
443 }
444
445 fn category(&self) -> RuleCategory {
447 RuleCategory::Heading
448 }
449
450 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
452 if ctx.content.is_empty() {
454 return true;
455 }
456
457 if !ctx.likely_has_headings() {
459 return true;
460 }
461
462 let mut target_level_count = 0;
464 for line_info in &ctx.lines {
465 if let Some(heading) = &line_info.heading
466 && heading.level as usize == self.config.level.as_usize()
467 {
468 if line_info.indent >= 4 || line_info.in_code_block {
470 continue;
471 }
472 target_level_count += 1;
473
474 if target_level_count > 1 {
477 return false;
478 }
479 }
480 }
481
482 target_level_count <= 1
484 }
485
486 fn as_any(&self) -> &dyn std::any::Any {
487 self
488 }
489
490 fn default_config_section(&self) -> Option<(String, toml::Value)> {
491 let json_value = serde_json::to_value(&self.config).ok()?;
492 Some((
493 self.name().to_string(),
494 crate::rule_config_serde::json_to_toml_value(&json_value)?,
495 ))
496 }
497
498 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
499 where
500 Self: Sized,
501 {
502 let rule_config = crate::rule_config_serde::load_rule_config::<MD025Config>(config);
503 Box::new(Self::from_config_struct(rule_config))
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn test_with_cached_headings() {
513 let rule = MD025SingleTitle::default();
514
515 let content = "# Title\n\n## Section 1\n\n## Section 2";
517 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
518 let result = rule.check(&ctx).unwrap();
519 assert!(result.is_empty());
520
521 let content = "# Title 1\n\n## Section 1\n\n# Another Title\n\n## Section 2";
523 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
524 let result = rule.check(&ctx).unwrap();
525 assert_eq!(result.len(), 1); assert_eq!(result[0].line, 5);
527
528 let content = "---\ntitle: Document Title\n---\n\n# Main Heading\n\n## Section 1";
530 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
531 let result = rule.check(&ctx).unwrap();
532 assert!(result.is_empty(), "Should not flag a single title after front matter");
533 }
534
535 #[test]
536 fn test_allow_document_sections() {
537 let config = md025_config::MD025Config {
539 allow_document_sections: true,
540 ..Default::default()
541 };
542 let rule = MD025SingleTitle::from_config_struct(config);
543
544 let valid_cases = vec![
546 "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content",
547 "# Introduction\n\nContent here\n\n# References\n\nRef content",
548 "# Guide\n\nMain content\n\n# Bibliography\n\nBib content",
549 "# Manual\n\nContent\n\n# Index\n\nIndex content",
550 "# Document\n\nContent\n\n# Conclusion\n\nFinal thoughts",
551 "# Tutorial\n\nContent\n\n# FAQ\n\nQuestions and answers",
552 "# Project\n\nContent\n\n# Acknowledgments\n\nThanks",
553 ];
554
555 for case in valid_cases {
556 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
557 let result = rule.check(&ctx).unwrap();
558 assert!(result.is_empty(), "Should not flag document sections in: {case}");
559 }
560
561 let invalid_cases = vec![
563 "# Main Title\n\n## Content\n\n# Random Other Title\n\nContent",
564 "# First\n\nContent\n\n# Second Title\n\nMore content",
565 ];
566
567 for case in invalid_cases {
568 let ctx = crate::lint_context::LintContext::new(case, crate::config::MarkdownFlavor::Standard, None);
569 let result = rule.check(&ctx).unwrap();
570 assert!(!result.is_empty(), "Should flag non-section headings in: {case}");
571 }
572 }
573
574 #[test]
575 fn test_strict_mode() {
576 let rule = MD025SingleTitle::strict(); let content = "# Main Title\n\n## Content\n\n# Appendix A\n\nAppendix content";
580 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
581 let result = rule.check(&ctx).unwrap();
582 assert_eq!(result.len(), 1, "Strict mode should flag all multiple H1s");
583 }
584
585 #[test]
586 fn test_bounds_checking_bug() {
587 let rule = MD025SingleTitle::default();
590
591 let content = "# First\n#";
593 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
594
595 let result = rule.check(&ctx);
597 assert!(result.is_ok());
598
599 let fix_result = rule.fix(&ctx);
601 assert!(fix_result.is_ok());
602 }
603
604 #[test]
605 fn test_bounds_checking_edge_case() {
606 let rule = MD025SingleTitle::default();
609
610 let content = "# First Title\n#";
614 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
615
616 let result = rule.check(&ctx);
618 assert!(result.is_ok());
619
620 if let Ok(warnings) = result
621 && !warnings.is_empty()
622 {
623 let fix_result = rule.fix(&ctx);
625 assert!(fix_result.is_ok());
626
627 if let Ok(fixed_content) = fix_result {
629 assert!(!fixed_content.is_empty());
630 assert!(fixed_content.contains("##"));
632 }
633 }
634 }
635
636 #[test]
637 fn test_horizontal_rule_separators() {
638 let config = md025_config::MD025Config {
640 allow_with_separators: true,
641 ..Default::default()
642 };
643 let rule = MD025SingleTitle::from_config_struct(config);
644
645 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.";
647 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648 let result = rule.check(&ctx).unwrap();
649 assert!(
650 result.is_empty(),
651 "Should not flag headings separated by horizontal rules"
652 );
653
654 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.";
656 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
657 let result = rule.check(&ctx).unwrap();
658 assert_eq!(result.len(), 1, "Should flag the heading without separator");
659 assert_eq!(result[0].line, 11); let strict_rule = MD025SingleTitle::strict();
663 let content = "# First Title\n\nContent here.\n\n---\n\n# Second Title\n\nMore content.";
664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
665 let result = strict_rule.check(&ctx).unwrap();
666 assert_eq!(
667 result.len(),
668 1,
669 "Strict mode should flag all multiple H1s regardless of separators"
670 );
671 }
672
673 #[test]
674 fn test_python_comments_in_code_blocks() {
675 let rule = MD025SingleTitle::default();
676
677 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.";
679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
680 let result = rule.check(&ctx).unwrap();
681 assert!(
682 result.is_empty(),
683 "Should not flag Python comments in code blocks as headings"
684 );
685
686 let content = "# Main Title\n\n```python\n# Python comment\nprint('test')\n```\n\n# Second Title";
688 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689 let fixed = rule.fix(&ctx).unwrap();
690 assert!(
691 fixed.contains("# Python comment"),
692 "Fix should preserve Python comments in code blocks"
693 );
694 assert!(
695 fixed.contains("## Second Title"),
696 "Fix should demote the actual second heading"
697 );
698 }
699}