1use crate::lint_context::LintContext;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::anchor_styles::AnchorStyle;
8use regex::Regex;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
14
15static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
17
18static TOC_ENTRY_PATTERN: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
23
24#[derive(Debug, Clone, Copy, PartialEq, Default)]
26pub enum TocDetection {
27 #[default]
29 Markers,
30 Heading,
32 Both,
34}
35
36#[derive(Debug, Clone)]
38struct TocRegion {
39 start_line: usize,
41 end_line: usize,
43 content_start: usize,
45 content_end: usize,
47}
48
49#[derive(Debug, Clone)]
51struct TocEntry {
52 text: String,
54 anchor: String,
56}
57
58#[derive(Debug, Clone)]
60struct ExpectedTocEntry {
61 heading_line: usize,
63 level: u8,
65 text: String,
67 anchor: String,
69}
70
71#[derive(Debug)]
73enum TocMismatch {
74 StaleEntry { entry: TocEntry },
76 MissingEntry { expected: ExpectedTocEntry },
78 TextMismatch {
80 entry: TocEntry,
81 expected: ExpectedTocEntry,
82 },
83 OrderMismatch { entry: TocEntry, expected_position: usize },
85}
86
87#[derive(Clone)]
113pub struct MD073TocValidation {
114 detection: TocDetection,
116 min_level: u8,
118 max_level: u8,
120 enforce_order: bool,
122 nested: bool,
124 anchor_style: AnchorStyle,
126 toc_headings: Vec<String>,
128}
129
130impl Default for MD073TocValidation {
131 fn default() -> Self {
132 Self {
133 detection: TocDetection::Both,
134 min_level: 2,
135 max_level: 4,
136 enforce_order: true,
137 nested: true,
138 anchor_style: AnchorStyle::GitHub,
139 toc_headings: vec![
140 "Table of Contents".to_string(),
141 "Contents".to_string(),
142 "TOC".to_string(),
143 ],
144 }
145 }
146}
147
148impl std::fmt::Debug for MD073TocValidation {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("MD073TocValidation")
151 .field("detection", &self.detection)
152 .field("min_level", &self.min_level)
153 .field("max_level", &self.max_level)
154 .field("enforce_order", &self.enforce_order)
155 .field("nested", &self.nested)
156 .field("toc_headings", &self.toc_headings)
157 .finish()
158 }
159}
160
161impl MD073TocValidation {
162 pub fn new() -> Self {
164 Self::default()
165 }
166
167 fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
169 let mut start_line = None;
170 let mut start_byte = None;
171
172 for (idx, line_info) in ctx.lines.iter().enumerate() {
173 let line_num = idx + 1;
174 let content = line_info.content(ctx.content);
175
176 if line_info.in_code_block || line_info.in_front_matter {
178 continue;
179 }
180
181 if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
183 if TOC_STOP_MARKER.is_match(content) {
185 let end_line = line_num - 1;
186 let content_end = line_info.byte_offset;
187
188 if end_line < s_line {
190 return Some(TocRegion {
191 start_line: s_line,
192 end_line: s_line,
193 content_start: s_byte,
194 content_end: s_byte,
195 });
196 }
197
198 return Some(TocRegion {
199 start_line: s_line,
200 end_line,
201 content_start: s_byte,
202 content_end,
203 });
204 }
205 } else if TOC_START_MARKER.is_match(content) {
206 if idx + 1 < ctx.lines.len() {
208 start_line = Some(line_num + 1);
209 start_byte = Some(ctx.lines[idx + 1].byte_offset);
210 }
211 }
212 }
213
214 None
215 }
216
217 fn detect_by_heading(&self, ctx: &LintContext) -> Option<TocRegion> {
219 let mut toc_heading_line = None;
220 let mut content_start_line = None;
221 let mut content_start_byte = None;
222 let mut blank_streak = 0usize;
223
224 for (idx, line_info) in ctx.lines.iter().enumerate() {
225 let line_num = idx + 1;
226
227 if line_info.in_code_block || line_info.in_front_matter {
229 continue;
230 }
231
232 if toc_heading_line.is_none() {
234 if let Some(heading) = &line_info.heading {
235 let heading_text = heading.text.trim();
237 if self.toc_headings.iter().any(|h| h.eq_ignore_ascii_case(heading_text)) {
238 toc_heading_line = Some(line_num);
239 if idx + 1 < ctx.lines.len() {
241 content_start_line = Some(line_num + 1);
242 content_start_byte = Some(ctx.lines[idx + 1].byte_offset);
243 }
244 }
245 }
246 } else if content_start_line.is_some() {
247 if line_info.heading.is_some() {
255 let end_line = line_num - 1;
256 let content_end = line_info.byte_offset;
257
258 let mut actual_end = end_line;
260 while actual_end >= content_start_line.unwrap() {
261 let check_idx = actual_end - 1;
262 if check_idx < ctx.lines.len() && ctx.lines[check_idx].is_blank {
263 actual_end -= 1;
264 } else {
265 break;
266 }
267 }
268
269 if actual_end < content_start_line.unwrap() {
270 actual_end = content_start_line.unwrap();
271 }
272
273 return Some(TocRegion {
274 start_line: content_start_line.unwrap(),
275 end_line: actual_end,
276 content_start: content_start_byte.unwrap(),
277 content_end,
278 });
279 }
280
281 if line_info.is_blank {
283 blank_streak += 1;
284 if blank_streak >= 2 {
285 let start_line = content_start_line.unwrap();
286 let first_blank_idx = idx - 1;
287 let mut end_line = line_num.saturating_sub(2);
288 if end_line < start_line {
289 end_line = start_line;
290 }
291
292 return Some(TocRegion {
293 start_line,
294 end_line,
295 content_start: content_start_byte.unwrap(),
296 content_end: ctx.lines[first_blank_idx].byte_offset,
297 });
298 }
299 } else {
300 blank_streak = 0;
301 }
302 }
303 }
304
305 if let (Some(start_line), Some(start_byte)) = (content_start_line, content_start_byte) {
307 let mut end_line = ctx.lines.len();
309 while end_line > start_line {
310 let check_idx = end_line - 1;
311 if check_idx < ctx.lines.len() && ctx.lines[check_idx].is_blank {
312 end_line -= 1;
313 } else {
314 break;
315 }
316 }
317
318 return Some(TocRegion {
319 start_line,
320 end_line,
321 content_start: start_byte,
322 content_end: ctx.content.len(),
323 });
324 }
325
326 None
327 }
328
329 fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
331 match self.detection {
332 TocDetection::Markers => self.detect_by_markers(ctx),
333 TocDetection::Heading => self.detect_by_heading(ctx),
334 TocDetection::Both => {
335 self.detect_by_markers(ctx).or_else(|| self.detect_by_heading(ctx))
337 }
338 }
339 }
340
341 fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
343 let mut entries = Vec::new();
344
345 for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
346 let line_info = &ctx.lines[idx];
347 let content = line_info.content(ctx.content);
348
349 if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
350 let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
351 let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
352
353 entries.push(TocEntry { text, anchor });
354 }
355 }
356
357 entries
358 }
359
360 fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
362 let mut entries = Vec::new();
363 let mut fragment_counts: HashMap<String, usize> = HashMap::new();
364
365 for (idx, line_info) in ctx.lines.iter().enumerate() {
366 let line_num = idx + 1;
367
368 if line_num <= toc_region.end_line {
370 continue;
372 }
373
374 if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
376 continue;
377 }
378
379 if let Some(heading) = &line_info.heading {
380 if heading.level < self.min_level || heading.level > self.max_level {
382 continue;
383 }
384
385 let base_anchor = if let Some(custom_id) = &heading.custom_id {
387 custom_id.clone()
388 } else {
389 self.anchor_style.generate_fragment(&heading.text)
390 };
391
392 let anchor = if let Some(count) = fragment_counts.get_mut(&base_anchor) {
394 let suffix = *count;
395 *count += 1;
396 format!("{base_anchor}-{suffix}")
397 } else {
398 fragment_counts.insert(base_anchor.clone(), 1);
399 base_anchor
400 };
401
402 entries.push(ExpectedTocEntry {
403 heading_line: line_num,
404 level: heading.level,
405 text: heading.text.clone(),
406 anchor,
407 });
408 }
409 }
410
411 entries
412 }
413
414 fn validate_toc(&self, actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<TocMismatch> {
416 let mut mismatches = Vec::new();
417
418 let expected_anchors: HashMap<&str, &ExpectedTocEntry> =
420 expected.iter().map(|e| (e.anchor.as_str(), e)).collect();
421
422 let actual_anchors: HashMap<&str, &TocEntry> = actual.iter().map(|e| (e.anchor.as_str(), e)).collect();
424
425 for entry in actual {
427 if !expected_anchors.contains_key(entry.anchor.as_str()) {
428 mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
429 }
430 }
431
432 for exp in expected {
434 if !actual_anchors.contains_key(exp.anchor.as_str()) {
435 mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
436 }
437 }
438
439 for entry in actual {
441 if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
442 if entry.text.trim() != exp.text.trim() {
444 mismatches.push(TocMismatch::TextMismatch {
445 entry: entry.clone(),
446 expected: (*exp).clone(),
447 });
448 }
449 }
450 }
451
452 if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
454 let expected_order: Vec<&str> = expected.iter().map(|e| e.anchor.as_str()).collect();
455
456 let mut expected_idx = 0;
458 for entry in actual {
459 if !expected_anchors.contains_key(entry.anchor.as_str()) {
461 continue;
462 }
463
464 while expected_idx < expected_order.len() && expected_order[expected_idx] != entry.anchor {
466 expected_idx += 1;
467 }
468
469 if expected_idx >= expected_order.len() {
470 let correct_pos = expected_order.iter().position(|a| *a == entry.anchor).unwrap_or(0);
472 let already_reported = mismatches.iter().any(|m| match m {
474 TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
475 TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
476 _ => false,
477 });
478 if !already_reported {
479 mismatches.push(TocMismatch::OrderMismatch {
480 entry: entry.clone(),
481 expected_position: correct_pos + 1,
482 });
483 }
484 } else {
485 expected_idx += 1;
486 }
487 }
488 }
489
490 mismatches
491 }
492
493 fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
495 if expected.is_empty() {
496 return String::new();
497 }
498
499 let mut result = String::new();
500 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
501
502 for entry in expected {
503 let indent = if self.nested {
504 let level_diff = entry.level.saturating_sub(base_level) as usize;
505 " ".repeat(level_diff)
506 } else {
507 String::new()
508 };
509
510 result.push_str(&format!("{indent}- [{}](#{})\n", entry.text, entry.anchor));
511 }
512
513 result
514 }
515}
516
517impl Rule for MD073TocValidation {
518 fn name(&self) -> &'static str {
519 "MD073"
520 }
521
522 fn description(&self) -> &'static str {
523 "Table of Contents should match document headings"
524 }
525
526 fn should_skip(&self, ctx: &LintContext) -> bool {
527 let has_toc_marker = ctx.content.contains("<!-- toc") || ctx.content.contains("<!--toc");
529 let has_toc_heading = self
530 .toc_headings
531 .iter()
532 .any(|h| ctx.content.to_lowercase().contains(&h.to_lowercase()));
533
534 !has_toc_marker && !has_toc_heading
535 }
536
537 fn check(&self, ctx: &LintContext) -> LintResult {
538 let mut warnings = Vec::new();
539
540 let Some(region) = self.detect_toc_region(ctx) else {
542 return Ok(warnings);
544 };
545
546 let actual_entries = self.extract_toc_entries(ctx, ®ion);
548
549 let expected_entries = self.build_expected_toc(ctx, ®ion);
551
552 if expected_entries.is_empty() && actual_entries.is_empty() {
554 return Ok(warnings);
555 }
556
557 let mismatches = self.validate_toc(&actual_entries, &expected_entries);
559
560 if !mismatches.is_empty() {
561 let mut details = Vec::new();
563
564 for mismatch in &mismatches {
565 match mismatch {
566 TocMismatch::StaleEntry { entry } => {
567 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
568 }
569 TocMismatch::MissingEntry { expected } => {
570 details.push(format!(
571 "Missing entry: '{}' (line {})",
572 expected.text, expected.heading_line
573 ));
574 }
575 TocMismatch::TextMismatch { entry, expected } => {
576 details.push(format!(
577 "Text mismatch: TOC has '{}', heading is '{}'",
578 entry.text, expected.text
579 ));
580 }
581 TocMismatch::OrderMismatch {
582 entry,
583 expected_position,
584 } => {
585 details.push(format!(
586 "Order mismatch: '{}' should be at position {}",
587 entry.text, expected_position
588 ));
589 }
590 }
591 }
592
593 let message = format!(
594 "Table of Contents does not match document headings: {}",
595 details.join("; ")
596 );
597
598 let new_toc = self.generate_toc(&expected_entries);
600 let fix_range = region.content_start..region.content_end;
601
602 warnings.push(LintWarning {
603 rule_name: Some(self.name().to_string()),
604 message,
605 line: region.start_line,
606 column: 1,
607 end_line: region.end_line,
608 end_column: 1,
609 severity: Severity::Warning,
610 fix: Some(Fix {
611 range: fix_range,
612 replacement: new_toc,
613 }),
614 });
615 }
616
617 Ok(warnings)
618 }
619
620 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
621 let Some(region) = self.detect_toc_region(ctx) else {
623 return Ok(ctx.content.to_string());
625 };
626
627 let expected_entries = self.build_expected_toc(ctx, ®ion);
629
630 let new_toc = self.generate_toc(&expected_entries);
632
633 let mut result = String::with_capacity(ctx.content.len());
635 result.push_str(&ctx.content[..region.content_start]);
636 result.push_str(&new_toc);
637 result.push_str(&ctx.content[region.content_end..]);
638
639 Ok(result)
640 }
641
642 fn category(&self) -> RuleCategory {
643 RuleCategory::Other
644 }
645
646 fn as_any(&self) -> &dyn std::any::Any {
647 self
648 }
649
650 fn default_config_section(&self) -> Option<(String, toml::Value)> {
651 let value: toml::Value = toml::from_str(
652 r#"
653# Detection method: "markers", "heading", or "both"
654detection = "both"
655# Minimum heading level to include
656min-level = 2
657# Maximum heading level to include
658max-level = 4
659# Whether TOC order must match document order
660enforce-order = true
661# Whether to use nested indentation
662nested = true
663# Anchor generation style
664anchor-style = "github"
665# Headings that indicate a TOC section
666toc-headings = ["Table of Contents", "Contents", "TOC"]
667"#,
668 )
669 .ok()?;
670 Some(("MD073".to_string(), value))
671 }
672
673 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
674 where
675 Self: Sized,
676 {
677 let mut rule = MD073TocValidation::default();
678
679 if let Some(rule_config) = config.rules.get("MD073") {
680 if let Some(detection_str) = rule_config.values.get("detection").and_then(|v| v.as_str()) {
682 rule.detection = match detection_str.to_lowercase().as_str() {
683 "markers" => TocDetection::Markers,
684 "heading" => TocDetection::Heading,
685 _ => TocDetection::Both,
686 };
687 }
688
689 if let Some(min_level) = rule_config.values.get("min-level").and_then(|v| v.as_integer()) {
691 rule.min_level = (min_level.clamp(1, 6)) as u8;
692 }
693
694 if let Some(max_level) = rule_config.values.get("max-level").and_then(|v| v.as_integer()) {
696 rule.max_level = (max_level.clamp(1, 6)) as u8;
697 }
698
699 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(|v| v.as_bool()) {
701 rule.enforce_order = enforce_order;
702 }
703
704 if let Some(nested) = rule_config.values.get("nested").and_then(|v| v.as_bool()) {
706 rule.nested = nested;
707 }
708
709 if let Some(style_str) = rule_config.values.get("anchor-style").and_then(|v| v.as_str()) {
711 rule.anchor_style = match style_str.to_lowercase().as_str() {
712 "kramdown" => AnchorStyle::Kramdown,
713 "kramdown-gfm" | "jekyll" => AnchorStyle::KramdownGfm,
714 _ => AnchorStyle::GitHub,
715 };
716 }
717
718 if let Some(headings) = rule_config.values.get("toc-headings").and_then(|v| v.as_array()) {
720 let custom_headings: Vec<String> = headings
721 .iter()
722 .filter_map(|v| v.as_str().map(|s| s.to_string()))
723 .collect();
724 if !custom_headings.is_empty() {
725 rule.toc_headings = custom_headings;
726 }
727 }
728 }
729
730 Box::new(rule)
731 }
732}
733
734#[cfg(test)]
735mod tests {
736 use super::*;
737 use crate::config::MarkdownFlavor;
738
739 fn create_ctx(content: &str) -> LintContext<'_> {
740 LintContext::new(content, MarkdownFlavor::Standard, None)
741 }
742
743 #[test]
746 fn test_detect_markers_basic() {
747 let rule = MD073TocValidation::new();
748 let content = r#"# Title
749
750<!-- toc -->
751
752- [Heading 1](#heading-1)
753
754<!-- tocstop -->
755
756## Heading 1
757
758Content here.
759"#;
760 let ctx = create_ctx(content);
761 let region = rule.detect_by_markers(&ctx);
762 assert!(region.is_some());
763 let region = region.unwrap();
764 assert_eq!(region.start_line, 4);
766 assert_eq!(region.end_line, 6);
767 }
768
769 #[test]
770 fn test_detect_markers_variations() {
771 let rule = MD073TocValidation::new();
772
773 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
775 let ctx1 = create_ctx(content1);
776 assert!(rule.detect_by_markers(&ctx1).is_some());
777
778 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
780 let ctx2 = create_ctx(content2);
781 assert!(rule.detect_by_markers(&ctx2).is_some());
782
783 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
785 let ctx3 = create_ctx(content3);
786 assert!(rule.detect_by_markers(&ctx3).is_some());
787 }
788
789 #[test]
790 fn test_detect_heading_table_of_contents() {
791 let mut rule = MD073TocValidation::new();
792 rule.detection = TocDetection::Heading;
793
794 let content = r#"# Title
795
796## Table of Contents
797
798- [Heading 1](#heading-1)
799- [Heading 2](#heading-2)
800
801## Heading 1
802
803Content.
804
805## Heading 2
806
807More content.
808"#;
809 let ctx = create_ctx(content);
810 let region = rule.detect_by_heading(&ctx);
811 assert!(region.is_some());
812 let region = region.unwrap();
813 assert_eq!(region.start_line, 4);
815 }
816
817 #[test]
818 fn test_detect_heading_ends_on_double_blank_lines() {
819 let mut rule = MD073TocValidation::new();
820 rule.detection = TocDetection::Heading;
821
822 let content = r#"# Title
823
824## Table of Contents
825
826- [Heading 1](#heading-1)
827
828
829This text is not part of TOC.
830
831## Heading 1
832
833Content.
834"#;
835 let ctx = create_ctx(content);
836 let region = rule.detect_by_heading(&ctx).unwrap();
837 assert_eq!(region.start_line, 4);
838 assert_eq!(region.end_line, 5);
839 }
840
841 #[test]
842 fn test_no_toc_region() {
843 let rule = MD073TocValidation::new();
844 let content = r#"# Title
845
846## Heading 1
847
848Content here.
849
850## Heading 2
851
852More content.
853"#;
854 let ctx = create_ctx(content);
855 let region = rule.detect_toc_region(&ctx);
856 assert!(region.is_none());
857 }
858
859 #[test]
862 fn test_toc_matches_headings() {
863 let rule = MD073TocValidation::new();
864 let content = r#"# Title
865
866<!-- toc -->
867
868- [Heading 1](#heading-1)
869- [Heading 2](#heading-2)
870
871<!-- tocstop -->
872
873## Heading 1
874
875Content.
876
877## Heading 2
878
879More content.
880"#;
881 let ctx = create_ctx(content);
882 let result = rule.check(&ctx).unwrap();
883 assert!(result.is_empty(), "Expected no warnings for matching TOC");
884 }
885
886 #[test]
887 fn test_missing_entry() {
888 let rule = MD073TocValidation::new();
889 let content = r#"# Title
890
891<!-- toc -->
892
893- [Heading 1](#heading-1)
894
895<!-- tocstop -->
896
897## Heading 1
898
899Content.
900
901## Heading 2
902
903New heading not in TOC.
904"#;
905 let ctx = create_ctx(content);
906 let result = rule.check(&ctx).unwrap();
907 assert_eq!(result.len(), 1);
908 assert!(result[0].message.contains("Missing entry"));
909 assert!(result[0].message.contains("Heading 2"));
910 }
911
912 #[test]
913 fn test_stale_entry() {
914 let rule = MD073TocValidation::new();
915 let content = r#"# Title
916
917<!-- toc -->
918
919- [Heading 1](#heading-1)
920- [Deleted Heading](#deleted-heading)
921
922<!-- tocstop -->
923
924## Heading 1
925
926Content.
927"#;
928 let ctx = create_ctx(content);
929 let result = rule.check(&ctx).unwrap();
930 assert_eq!(result.len(), 1);
931 assert!(result[0].message.contains("Stale entry"));
932 assert!(result[0].message.contains("Deleted Heading"));
933 }
934
935 #[test]
936 fn test_text_mismatch() {
937 let rule = MD073TocValidation::new();
938 let content = r#"# Title
939
940<!-- toc -->
941
942- [Old Name](#heading-1)
943
944<!-- tocstop -->
945
946## Heading 1
947
948Content.
949"#;
950 let ctx = create_ctx(content);
951 let result = rule.check(&ctx).unwrap();
952 assert_eq!(result.len(), 1);
953 assert!(result[0].message.contains("Text mismatch"));
954 }
955
956 #[test]
959 fn test_min_level_excludes_h1() {
960 let mut rule = MD073TocValidation::new();
961 rule.min_level = 2;
962
963 let content = r#"<!-- toc -->
964
965<!-- tocstop -->
966
967# Should Be Excluded
968
969## Should Be Included
970
971Content.
972"#;
973 let ctx = create_ctx(content);
974 let region = rule.detect_toc_region(&ctx).unwrap();
975 let expected = rule.build_expected_toc(&ctx, ®ion);
976
977 assert_eq!(expected.len(), 1);
978 assert_eq!(expected[0].text, "Should Be Included");
979 }
980
981 #[test]
982 fn test_max_level_excludes_h5_h6() {
983 let mut rule = MD073TocValidation::new();
984 rule.max_level = 4;
985
986 let content = r#"<!-- toc -->
987
988<!-- tocstop -->
989
990## Level 2
991
992### Level 3
993
994#### Level 4
995
996##### Level 5 Should Be Excluded
997
998###### Level 6 Should Be Excluded
999"#;
1000 let ctx = create_ctx(content);
1001 let region = rule.detect_toc_region(&ctx).unwrap();
1002 let expected = rule.build_expected_toc(&ctx, ®ion);
1003
1004 assert_eq!(expected.len(), 3);
1005 assert!(expected.iter().all(|e| e.level <= 4));
1006 }
1007
1008 #[test]
1011 fn test_fix_adds_missing_entry() {
1012 let rule = MD073TocValidation::new();
1013 let content = r#"# Title
1014
1015<!-- toc -->
1016
1017- [Heading 1](#heading-1)
1018
1019<!-- tocstop -->
1020
1021## Heading 1
1022
1023Content.
1024
1025## Heading 2
1026
1027New heading.
1028"#;
1029 let ctx = create_ctx(content);
1030 let fixed = rule.fix(&ctx).unwrap();
1031 assert!(fixed.contains("- [Heading 2](#heading-2)"));
1032 }
1033
1034 #[test]
1035 fn test_fix_removes_stale_entry() {
1036 let rule = MD073TocValidation::new();
1037 let content = r#"# Title
1038
1039<!-- toc -->
1040
1041- [Heading 1](#heading-1)
1042- [Deleted](#deleted)
1043
1044<!-- tocstop -->
1045
1046## Heading 1
1047
1048Content.
1049"#;
1050 let ctx = create_ctx(content);
1051 let fixed = rule.fix(&ctx).unwrap();
1052 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1053 assert!(!fixed.contains("Deleted"));
1054 }
1055
1056 #[test]
1057 fn test_fix_idempotent() {
1058 let rule = MD073TocValidation::new();
1059 let content = r#"# Title
1060
1061<!-- toc -->
1062
1063- [Heading 1](#heading-1)
1064- [Heading 2](#heading-2)
1065
1066<!-- tocstop -->
1067
1068## Heading 1
1069
1070Content.
1071
1072## Heading 2
1073
1074More.
1075"#;
1076 let ctx = create_ctx(content);
1077 let fixed1 = rule.fix(&ctx).unwrap();
1078 let ctx2 = create_ctx(&fixed1);
1079 let fixed2 = rule.fix(&ctx2).unwrap();
1080
1081 assert_eq!(fixed1, fixed2);
1083 }
1084
1085 #[test]
1086 fn test_fix_preserves_markers() {
1087 let rule = MD073TocValidation::new();
1088 let content = r#"# Title
1089
1090<!-- toc -->
1091
1092Old TOC content.
1093
1094<!-- tocstop -->
1095
1096## New Heading
1097
1098Content.
1099"#;
1100 let ctx = create_ctx(content);
1101 let fixed = rule.fix(&ctx).unwrap();
1102
1103 assert!(fixed.contains("<!-- toc -->"));
1105 assert!(fixed.contains("<!-- tocstop -->"));
1106 assert!(fixed.contains("- [New Heading](#new-heading)"));
1108 }
1109
1110 #[test]
1111 fn test_fix_heading_detection_stops_on_double_blank_lines() {
1112 let mut rule = MD073TocValidation::new();
1113 rule.detection = TocDetection::Heading;
1114
1115 let content = r#"# Title
1116
1117## Table of Contents
1118
1119- [Heading 1](#heading-1)
1120
1121
1122This text is not part of TOC.
1123
1124## Heading 1
1125
1126Content.
1127"#;
1128 let ctx = create_ctx(content);
1129 let fixed = rule.fix(&ctx).unwrap();
1130
1131 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1132 assert!(fixed.contains("This text is not part of TOC."));
1133 }
1134
1135 #[test]
1138 fn test_duplicate_heading_anchors() {
1139 let rule = MD073TocValidation::new();
1140 let content = r#"# Title
1141
1142<!-- toc -->
1143
1144<!-- tocstop -->
1145
1146## Duplicate
1147
1148Content.
1149
1150## Duplicate
1151
1152More content.
1153
1154## Duplicate
1155
1156Even more.
1157"#;
1158 let ctx = create_ctx(content);
1159 let region = rule.detect_toc_region(&ctx).unwrap();
1160 let expected = rule.build_expected_toc(&ctx, ®ion);
1161
1162 assert_eq!(expected.len(), 3);
1163 assert_eq!(expected[0].anchor, "duplicate");
1164 assert_eq!(expected[1].anchor, "duplicate-1");
1165 assert_eq!(expected[2].anchor, "duplicate-2");
1166 }
1167
1168 #[test]
1171 fn test_headings_in_code_blocks_ignored() {
1172 let rule = MD073TocValidation::new();
1173 let content = r#"# Title
1174
1175<!-- toc -->
1176
1177- [Real Heading](#real-heading)
1178
1179<!-- tocstop -->
1180
1181## Real Heading
1182
1183```markdown
1184## Fake Heading In Code
1185```
1186
1187Content.
1188"#;
1189 let ctx = create_ctx(content);
1190 let result = rule.check(&ctx).unwrap();
1191 assert!(result.is_empty(), "Should not report fake heading in code block");
1192 }
1193
1194 #[test]
1195 fn test_empty_toc_region() {
1196 let rule = MD073TocValidation::new();
1197 let content = r#"# Title
1198
1199<!-- toc -->
1200<!-- tocstop -->
1201
1202## Heading 1
1203
1204Content.
1205"#;
1206 let ctx = create_ctx(content);
1207 let result = rule.check(&ctx).unwrap();
1208 assert_eq!(result.len(), 1);
1209 assert!(result[0].message.contains("Missing entry"));
1210 }
1211
1212 #[test]
1213 fn test_nested_indentation() {
1214 let mut rule = MD073TocValidation::new();
1215 rule.nested = true;
1216
1217 let content = r#"<!-- toc -->
1218
1219<!-- tocstop -->
1220
1221## Level 2
1222
1223### Level 3
1224
1225#### Level 4
1226
1227## Another Level 2
1228"#;
1229 let ctx = create_ctx(content);
1230 let region = rule.detect_toc_region(&ctx).unwrap();
1231 let expected = rule.build_expected_toc(&ctx, ®ion);
1232 let toc = rule.generate_toc(&expected);
1233
1234 assert!(toc.contains("- [Level 2](#level-2)"));
1236 assert!(toc.contains(" - [Level 3](#level-3)"));
1237 assert!(toc.contains(" - [Level 4](#level-4)"));
1238 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1239 }
1240
1241 #[test]
1242 fn test_flat_no_indentation() {
1243 let mut rule = MD073TocValidation::new();
1244 rule.nested = false;
1245
1246 let content = r#"<!-- toc -->
1247
1248<!-- tocstop -->
1249
1250## Level 2
1251
1252### Level 3
1253
1254#### Level 4
1255"#;
1256 let ctx = create_ctx(content);
1257 let region = rule.detect_toc_region(&ctx).unwrap();
1258 let expected = rule.build_expected_toc(&ctx, ®ion);
1259 let toc = rule.generate_toc(&expected);
1260
1261 for line in toc.lines() {
1263 if !line.is_empty() {
1264 assert!(line.starts_with("- ["), "Line should start without indent: {line}");
1265 }
1266 }
1267 }
1268
1269 #[test]
1272 fn test_order_mismatch_detected() {
1273 let rule = MD073TocValidation::new();
1274 let content = r#"# Title
1275
1276<!-- toc -->
1277
1278- [Section B](#section-b)
1279- [Section A](#section-a)
1280
1281<!-- tocstop -->
1282
1283## Section A
1284
1285Content A.
1286
1287## Section B
1288
1289Content B.
1290"#;
1291 let ctx = create_ctx(content);
1292 let result = rule.check(&ctx).unwrap();
1293 assert!(!result.is_empty(), "Should detect order mismatch");
1296 }
1297
1298 #[test]
1299 fn test_order_mismatch_ignored_when_disabled() {
1300 let mut rule = MD073TocValidation::new();
1301 rule.enforce_order = false;
1302 let content = r#"# Title
1303
1304<!-- toc -->
1305
1306- [Section B](#section-b)
1307- [Section A](#section-a)
1308
1309<!-- tocstop -->
1310
1311## Section A
1312
1313Content A.
1314
1315## Section B
1316
1317Content B.
1318"#;
1319 let ctx = create_ctx(content);
1320 let result = rule.check(&ctx).unwrap();
1321 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1323 }
1324
1325 #[test]
1328 fn test_unicode_headings() {
1329 let rule = MD073TocValidation::new();
1330 let content = r#"# Title
1331
1332<!-- toc -->
1333
1334- [日本語の見出し](#日本語の見出し)
1335- [Émojis 🎉](#émojis-)
1336
1337<!-- tocstop -->
1338
1339## 日本語の見出し
1340
1341Japanese content.
1342
1343## Émojis 🎉
1344
1345Content with emojis.
1346"#;
1347 let ctx = create_ctx(content);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(result.is_empty(), "Should handle unicode headings");
1351 }
1352
1353 #[test]
1354 fn test_special_characters_in_headings() {
1355 let rule = MD073TocValidation::new();
1356 let content = r#"# Title
1357
1358<!-- toc -->
1359
1360- [What's New?](#whats-new)
1361- [C++ Guide](#c-guide)
1362
1363<!-- tocstop -->
1364
1365## What's New?
1366
1367News content.
1368
1369## C++ Guide
1370
1371C++ content.
1372"#;
1373 let ctx = create_ctx(content);
1374 let result = rule.check(&ctx).unwrap();
1375 assert!(result.is_empty(), "Should handle special characters");
1376 }
1377
1378 #[test]
1379 fn test_code_spans_in_headings() {
1380 let rule = MD073TocValidation::new();
1381 let content = r#"# Title
1382
1383<!-- toc -->
1384
1385- [`check [PATHS...]`](#check-paths)
1386
1387<!-- tocstop -->
1388
1389## `check [PATHS...]`
1390
1391Command documentation.
1392"#;
1393 let ctx = create_ctx(content);
1394 let result = rule.check(&ctx).unwrap();
1395 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1396 }
1397
1398 #[test]
1401 fn test_from_config_defaults() {
1402 let config = crate::config::Config::default();
1403 let rule = MD073TocValidation::from_config(&config);
1404 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1405
1406 assert_eq!(rule.min_level, 2);
1407 assert_eq!(rule.max_level, 4);
1408 assert!(rule.enforce_order);
1409 assert!(rule.nested);
1410 }
1411
1412 #[test]
1415 fn test_custom_anchor_id_respected() {
1416 let rule = MD073TocValidation::new();
1417 let content = r#"# Title
1418
1419<!-- toc -->
1420
1421- [My Section](#my-custom-anchor)
1422
1423<!-- tocstop -->
1424
1425## My Section {#my-custom-anchor}
1426
1427Content here.
1428"#;
1429 let ctx = create_ctx(content);
1430 let result = rule.check(&ctx).unwrap();
1431 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1432 }
1433
1434 #[test]
1435 fn test_custom_anchor_id_in_generated_toc() {
1436 let rule = MD073TocValidation::new();
1437 let content = r#"# Title
1438
1439<!-- toc -->
1440
1441<!-- tocstop -->
1442
1443## First Section {#custom-first}
1444
1445Content.
1446
1447## Second Section {#another-custom}
1448
1449More content.
1450"#;
1451 let ctx = create_ctx(content);
1452 let fixed = rule.fix(&ctx).unwrap();
1453 assert!(fixed.contains("- [First Section](#custom-first)"));
1454 assert!(fixed.contains("- [Second Section](#another-custom)"));
1455 }
1456
1457 #[test]
1458 fn test_mixed_custom_and_generated_anchors() {
1459 let rule = MD073TocValidation::new();
1460 let content = r#"# Title
1461
1462<!-- toc -->
1463
1464- [Custom Section](#my-id)
1465- [Normal Section](#normal-section)
1466
1467<!-- tocstop -->
1468
1469## Custom Section {#my-id}
1470
1471Content.
1472
1473## Normal Section
1474
1475More content.
1476"#;
1477 let ctx = create_ctx(content);
1478 let result = rule.check(&ctx).unwrap();
1479 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
1480 }
1481
1482 #[test]
1485 fn test_github_anchor_style_default() {
1486 let rule = MD073TocValidation::new();
1487 assert_eq!(rule.anchor_style, AnchorStyle::GitHub);
1488
1489 let content = r#"<!-- toc -->
1490
1491<!-- tocstop -->
1492
1493## Test_With_Underscores
1494
1495Content.
1496"#;
1497 let ctx = create_ctx(content);
1498 let region = rule.detect_toc_region(&ctx).unwrap();
1499 let expected = rule.build_expected_toc(&ctx, ®ion);
1500
1501 assert_eq!(expected[0].anchor, "test_with_underscores");
1503 }
1504
1505 #[test]
1506 fn test_kramdown_anchor_style() {
1507 let mut rule = MD073TocValidation::new();
1508 rule.anchor_style = AnchorStyle::Kramdown;
1509
1510 let content = r#"<!-- toc -->
1511
1512<!-- tocstop -->
1513
1514## Test_With_Underscores
1515
1516Content.
1517"#;
1518 let ctx = create_ctx(content);
1519 let region = rule.detect_toc_region(&ctx).unwrap();
1520 let expected = rule.build_expected_toc(&ctx, ®ion);
1521
1522 assert_eq!(expected[0].anchor, "testwithunderscores");
1524 }
1525
1526 #[test]
1527 fn test_kramdown_gfm_anchor_style() {
1528 let mut rule = MD073TocValidation::new();
1529 rule.anchor_style = AnchorStyle::KramdownGfm;
1530
1531 let content = r#"<!-- toc -->
1532
1533<!-- tocstop -->
1534
1535## Test_With_Underscores
1536
1537Content.
1538"#;
1539 let ctx = create_ctx(content);
1540 let region = rule.detect_toc_region(&ctx).unwrap();
1541 let expected = rule.build_expected_toc(&ctx, ®ion);
1542
1543 assert_eq!(expected[0].anchor, "test_with_underscores");
1545 }
1546
1547 #[test]
1550 fn test_stress_many_headings() {
1551 let rule = MD073TocValidation::new();
1552
1553 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1555
1556 for i in 1..=150 {
1557 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
1558 }
1559
1560 let ctx = create_ctx(&content);
1561
1562 let result = rule.check(&ctx).unwrap();
1564
1565 assert_eq!(result.len(), 1, "Should report single warning for TOC");
1567 assert!(result[0].message.contains("Missing entry"));
1568
1569 let fixed = rule.fix(&ctx).unwrap();
1571 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
1572 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
1573 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
1574 }
1575
1576 #[test]
1577 fn test_stress_deeply_nested() {
1578 let rule = MD073TocValidation::new();
1579 let content = r#"# Title
1580
1581<!-- toc -->
1582
1583<!-- tocstop -->
1584
1585## Level 2 A
1586
1587### Level 3 A
1588
1589#### Level 4 A
1590
1591## Level 2 B
1592
1593### Level 3 B
1594
1595#### Level 4 B
1596
1597## Level 2 C
1598
1599### Level 3 C
1600
1601#### Level 4 C
1602
1603## Level 2 D
1604
1605### Level 3 D
1606
1607#### Level 4 D
1608"#;
1609 let ctx = create_ctx(content);
1610 let fixed = rule.fix(&ctx).unwrap();
1611
1612 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
1614 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
1615 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
1616 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
1617 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
1618 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
1619 }
1620
1621 #[test]
1622 fn test_stress_many_duplicates() {
1623 let rule = MD073TocValidation::new();
1624
1625 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1627 for _ in 0..50 {
1628 content.push_str("## FAQ\n\nContent.\n\n");
1629 }
1630
1631 let ctx = create_ctx(&content);
1632 let region = rule.detect_toc_region(&ctx).unwrap();
1633 let expected = rule.build_expected_toc(&ctx, ®ion);
1634
1635 assert_eq!(expected.len(), 50);
1637 assert_eq!(expected[0].anchor, "faq");
1638 assert_eq!(expected[1].anchor, "faq-1");
1639 assert_eq!(expected[49].anchor, "faq-49");
1640 }
1641}