1use crate::lint_context::LintContext;
6use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::anchor_styles::AnchorStyle;
8use crate::utils::header_id_utils::{extract_html_anchor_ids, is_backslash_escaped};
9use percent_encoding::percent_decode_str;
10use regex::Regex;
11use std::borrow::Cow;
12use std::collections::HashMap;
13use std::sync::LazyLock;
14
15static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
17
18static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
20
21static TOC_ENTRY_PATTERN: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
26
27#[derive(Debug, Clone)]
29struct TocRegion {
30 start_line: usize,
32 end_line: usize,
34 content_start: usize,
36 content_end: usize,
38}
39
40#[derive(Debug, Clone)]
42struct TocEntry {
43 text: String,
45 anchor: String,
47 indent_spaces: usize,
49}
50
51#[derive(Debug, Clone)]
53struct ExpectedTocEntry {
54 heading_line: usize,
56 level: u8,
58 text: String,
60 anchor: String,
62 aliases: Vec<String>,
66}
67
68impl ExpectedTocEntry {
69 fn is_reached_by(&self, anchor: &str) -> bool {
70 self.anchor == anchor || self.aliases.iter().any(|alias| alias == anchor)
71 }
72}
73
74#[derive(Debug)]
76enum TocMismatch {
77 StaleEntry { entry: TocEntry },
79 MissingEntry { expected: ExpectedTocEntry },
81 TextMismatch {
83 entry: TocEntry,
84 expected: ExpectedTocEntry,
85 },
86 OrderMismatch { entry: TocEntry, expected_position: usize },
88 IndentationMismatch {
90 entry: TocEntry,
91 actual_indent: usize,
92 expected_indent: usize,
93 },
94}
95
96static MARKDOWN_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
98static MARKDOWN_REF_LINK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\[[^\]]*\]").unwrap());
99static MARKDOWN_IMAGE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!\[([^\]]*)\]\([^)]+\)").unwrap());
100
101fn encode_fragment(anchor: &str) -> Cow<'_, str> {
109 fn needs_encoding(c: char) -> bool {
110 c.is_ascii() && (c <= ' ' || c == '\x7f' || matches!(c, '(' | ')' | '%'))
111 }
112
113 if !anchor.contains(needs_encoding) {
114 return Cow::Borrowed(anchor);
115 }
116 let mut encoded = String::with_capacity(anchor.len() + 6);
117 for c in anchor.chars() {
118 if needs_encoding(c) {
119 encoded.push_str(&format!("%{:02X}", c as u32));
120 } else {
121 encoded.push(c);
122 }
123 }
124 Cow::Owned(encoded)
125}
126
127fn decode_fragment(anchor: &str) -> Cow<'_, str> {
131 if !anchor.contains('%') {
132 return Cow::Borrowed(anchor);
133 }
134 percent_decode_str(anchor)
135 .decode_utf8()
136 .unwrap_or(Cow::Borrowed(anchor))
137}
138
139fn code_span_ranges(text: &str) -> Vec<(usize, usize)> {
144 let chars: Vec<char> = text.chars().collect();
145 let len = chars.len();
146 let mut ranges = Vec::new();
147 let mut i = 0;
148
149 while i < len {
150 if chars[i] == '`' {
151 let span_start = i;
152 while i < len && chars[i] == '`' {
153 i += 1;
154 }
155 let n = i - span_start;
156
157 let mut j = i;
159 let mut found = false;
160 while j < len {
161 if chars[j] == '`' {
162 let close_start = j;
163 while j < len && chars[j] == '`' {
164 j += 1;
165 }
166 if j - close_start == n {
167 let byte_start: usize = text.char_indices().nth(span_start).map_or(0, |(b, _)| b);
169 let byte_end: usize = text.char_indices().nth(j).map_or(text.len(), |(b, _)| b);
170 ranges.push((byte_start, byte_end));
171 i = j;
172 found = true;
173 break;
174 }
175 } else {
176 j += 1;
177 }
178 }
179 if !found {
180 i = span_start + n;
182 }
183 } else {
184 i += 1;
185 }
186 }
187
188 ranges
189}
190
191fn strip_links_and_images(text: &str) -> String {
210 let protected = code_span_ranges(text);
213
214 if protected.is_empty() {
216 let mut result = text.to_string();
217 result = MARKDOWN_IMAGE.replace_all(&result, "$1").to_string();
218 result = MARKDOWN_LINK.replace_all(&result, "$1").to_string();
219 result = MARKDOWN_REF_LINK.replace_all(&result, "$1").to_string();
220 return result;
221 }
222
223 let mut placeholders: Vec<(&str, String)> = Vec::with_capacity(protected.len());
226 let mut masked = text.to_string();
227 for (i, &(start, end)) in protected.iter().enumerate().rev() {
229 let placeholder = format!("\x00CODESPAN{i}\x00");
231 let original = &text[start..end];
232 placeholders.push((original, placeholder.clone()));
233 masked.replace_range(start..end, &placeholder);
234 }
235
236 masked = MARKDOWN_IMAGE.replace_all(&masked, "$1").to_string();
238 masked = MARKDOWN_LINK.replace_all(&masked, "$1").to_string();
239 masked = MARKDOWN_REF_LINK.replace_all(&masked, "$1").to_string();
240
241 for (original, placeholder) in &placeholders {
243 masked = masked.replace(placeholder.as_str(), original);
244 }
245
246 masked
247}
248
249#[derive(Clone)]
277pub struct MD073TocValidation {
278 enabled: bool,
280 min_level: u8,
282 max_level: u8,
284 enforce_order: bool,
286 pub indent: usize,
288}
289
290impl Default for MD073TocValidation {
291 fn default() -> Self {
292 Self {
293 enabled: false, min_level: 2,
295 max_level: 4,
296 enforce_order: true,
297 indent: 2, }
299 }
300}
301
302impl std::fmt::Debug for MD073TocValidation {
303 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304 f.debug_struct("MD073TocValidation")
305 .field("enabled", &self.enabled)
306 .field("min_level", &self.min_level)
307 .field("max_level", &self.max_level)
308 .field("enforce_order", &self.enforce_order)
309 .field("indent", &self.indent)
310 .finish()
311 }
312}
313
314impl MD073TocValidation {
315 pub fn new() -> Self {
317 Self::default()
318 }
319
320 fn has_active_marker(ctx: &LintContext, content: &str, line_byte_offset: usize, marker: &Regex) -> bool {
324 marker.find_iter(content).any(|matched| {
325 !ctx.is_in_code_span_byte(line_byte_offset + matched.start())
326 && !is_backslash_escaped(content, matched.start())
327 })
328 }
329
330 fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
332 let mut start_line = None;
333 let mut start_byte = None;
334
335 for (idx, line_info) in ctx.lines.iter().enumerate() {
336 let line_num = idx + 1;
337 let content = line_info.content(ctx.content);
338
339 if line_info.in_code_block || line_info.in_front_matter {
341 continue;
342 }
343
344 if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
346 if Self::has_active_marker(ctx, content, line_info.byte_offset, &TOC_STOP_MARKER) {
348 let end_line = line_num - 1;
349 let content_end = line_info.byte_offset;
350
351 if end_line < s_line {
353 return Some(TocRegion {
354 start_line: s_line,
355 end_line: s_line,
356 content_start: s_byte,
357 content_end: s_byte,
358 });
359 }
360
361 return Some(TocRegion {
362 start_line: s_line,
363 end_line,
364 content_start: s_byte,
365 content_end,
366 });
367 }
368 } else if Self::has_active_marker(ctx, content, line_info.byte_offset, &TOC_START_MARKER) {
369 if idx + 1 < ctx.lines.len() {
371 start_line = Some(line_num + 1);
372 start_byte = Some(ctx.lines[idx + 1].byte_offset);
373 }
374 }
375 }
376
377 None
378 }
379
380 fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
382 self.detect_by_markers(ctx)
383 }
384
385 fn strip_quote_markers(line: &str, depth: usize) -> &str {
388 let mut rest = line;
389 for _ in 0..depth {
390 let Some(after) = rest.trim_start().strip_prefix('>') else {
391 break;
392 };
393 rest = after.strip_prefix(' ').unwrap_or(after);
394 }
395 rest
396 }
397
398 fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
400 let mut entries = Vec::new();
401
402 for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
403 let line_info = &ctx.lines[idx];
404 let content = line_info.blockquote.as_ref().map_or_else(
407 || line_info.content(ctx.content),
408 |quote| Self::strip_quote_markers(line_info.content(ctx.content), quote.nesting_level),
409 );
410
411 if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
412 let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
413 let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
414 let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
415
416 entries.push(TocEntry {
417 text,
418 anchor,
419 indent_spaces,
420 });
421 }
422 }
423
424 entries
425 }
426
427 fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
429 let mut entries = Vec::new();
430 let mut fragment_counts: HashMap<String, usize> = HashMap::new();
431
432 for parsed in ctx.headings() {
436 let line_num = parsed.line_num;
437 let line_info = parsed.line_info;
438 let heading = parsed.heading;
439
440 if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
442 continue;
443 }
444
445 let (anchor, aliases) = match &heading.custom_id {
451 Some(custom_id) => (
452 Self::deduplicate_fragment(&mut fragment_counts, custom_id),
453 extract_html_anchor_ids(&heading.raw_text),
454 ),
455 None => {
456 let slug = AnchorStyle::GitHub.generate_fragment(&heading.slug_text);
457 let generated = Self::deduplicate_fragment(&mut fragment_counts, &slug);
458 let mut explicit = extract_html_anchor_ids(&heading.raw_text);
459 match explicit.first().cloned() {
460 Some(preferred) => {
461 explicit.remove(0);
462 explicit.push(generated);
463 (preferred, explicit)
464 }
465 None if slug.is_empty() => continue,
470 None => (generated, Vec::new()),
471 }
472 }
473 };
474
475 if line_num <= toc_region.end_line || heading.level < self.min_level || heading.level > self.max_level {
478 continue;
479 }
480
481 entries.push(ExpectedTocEntry {
482 heading_line: parsed.first_line_num(),
485 level: heading.level,
486 text: heading.text.clone(),
487 anchor,
488 aliases,
489 });
490 }
491
492 entries
493 }
494
495 fn deduplicate_fragment(fragment_counts: &mut HashMap<String, usize>, base: &str) -> String {
498 match fragment_counts.get_mut(base) {
499 Some(count) => {
500 let suffix = *count;
501 *count += 1;
502 format!("{base}-{suffix}")
503 }
504 None => {
505 fragment_counts.insert(base.to_string(), 1);
506 base.to_string()
507 }
508 }
509 }
510
511 fn match_entries(actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<Option<usize>> {
514 let mut claimed = vec![false; expected.len()];
515 actual
516 .iter()
517 .map(|entry| {
518 let anchor = decode_fragment(&entry.anchor);
519 let found = expected
520 .iter()
521 .enumerate()
522 .position(|(idx, exp)| !claimed[idx] && exp.is_reached_by(&anchor));
523 if let Some(idx) = found {
524 claimed[idx] = true;
525 }
526 found
527 })
528 .collect()
529 }
530
531 fn validate_toc(
536 &self,
537 actual: &[TocEntry],
538 expected: &[ExpectedTocEntry],
539 matching: &[Option<usize>],
540 ) -> Vec<TocMismatch> {
541 let mut mismatches = Vec::new();
542 let pairs = || {
543 actual
544 .iter()
545 .zip(matching)
546 .enumerate()
547 .filter_map(|(actual_idx, (entry, matched))| matched.map(|exp_idx| (actual_idx, entry, exp_idx)))
548 };
549
550 for (entry, matched) in actual.iter().zip(matching) {
552 if matched.is_none() {
553 mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
554 }
555 }
556
557 for (exp_idx, exp) in expected.iter().enumerate() {
559 if !matching.contains(&Some(exp_idx)) {
560 mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
561 }
562 }
563
564 let mut text_mismatched = vec![false; actual.len()];
569 for (actual_idx, entry, exp_idx) in pairs() {
570 let exp = &expected[exp_idx];
571 let actual_normalized = strip_links_and_images(entry.text.trim());
572 let expected_normalized = strip_links_and_images(exp.text.trim());
573 if actual_normalized != expected_normalized {
574 text_mismatched[actual_idx] = true;
575 mismatches.push(TocMismatch::TextMismatch {
576 entry: entry.clone(),
577 expected: exp.clone(),
578 });
579 }
580 }
581
582 if !expected.is_empty() {
585 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
586
587 for (actual_idx, entry, exp_idx) in pairs() {
588 let level_diff = expected[exp_idx].level.saturating_sub(base_level) as usize;
589 let expected_indent = level_diff * self.indent;
590
591 if entry.indent_spaces != expected_indent && !text_mismatched[actual_idx] {
593 mismatches.push(TocMismatch::IndentationMismatch {
594 entry: entry.clone(),
595 actual_indent: entry.indent_spaces,
596 expected_indent,
597 });
598 }
599 }
600 }
601
602 if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
606 let mut expected_idx = 0;
607 for (actual_idx, entry, exp_idx) in pairs() {
608 if exp_idx >= expected_idx {
609 expected_idx = exp_idx + 1;
610 } else if !text_mismatched[actual_idx] {
611 mismatches.push(TocMismatch::OrderMismatch {
612 entry: entry.clone(),
613 expected_position: exp_idx + 1,
614 });
615 }
616 }
617 }
618
619 mismatches
620 }
621
622 fn keep_reached_anchors(
626 actual: &[TocEntry],
627 expected: &[ExpectedTocEntry],
628 matching: &[Option<usize>],
629 ) -> Vec<ExpectedTocEntry> {
630 let mut regenerated = expected.to_vec();
631 for (entry, exp_idx) in actual
632 .iter()
633 .zip(matching)
634 .filter_map(|(entry, m)| m.map(|idx| (entry, idx)))
635 {
636 regenerated[exp_idx].anchor = decode_fragment(&entry.anchor).into_owned();
637 }
638 regenerated
639 }
640
641 fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
643 if expected.is_empty() {
644 return String::new();
645 }
646
647 let mut result = String::new();
648 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
649 let indent_str = " ".repeat(self.indent);
650
651 for entry in expected {
652 let level_diff = entry.level.saturating_sub(base_level) as usize;
653 let indent = indent_str.repeat(level_diff);
654
655 let display_text = strip_links_and_images(&entry.text);
659 let fragment = encode_fragment(&entry.anchor);
660 result.push_str(&format!("{indent}- [{display_text}](#{fragment})\n"));
661 }
662
663 result
664 }
665}
666
667impl Rule for MD073TocValidation {
668 fn name(&self) -> &'static str {
669 "MD073"
670 }
671
672 fn description(&self) -> &'static str {
673 "Table of Contents should match document headings"
674 }
675
676 fn should_skip(&self, ctx: &LintContext) -> bool {
677 let lower = ctx.content.to_ascii_lowercase();
682 !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
683 }
684
685 fn check(&self, ctx: &LintContext) -> LintResult {
686 let mut warnings = Vec::new();
687
688 let Some(region) = self.detect_toc_region(ctx) else {
690 return Ok(warnings);
692 };
693
694 let actual_entries = self.extract_toc_entries(ctx, ®ion);
696
697 let expected_entries = self.build_expected_toc(ctx, ®ion);
699
700 if expected_entries.is_empty() && actual_entries.is_empty() {
702 return Ok(warnings);
703 }
704
705 let matching = Self::match_entries(&actual_entries, &expected_entries);
707 let mismatches = self.validate_toc(&actual_entries, &expected_entries, &matching);
708
709 if !mismatches.is_empty() {
710 let mut details = Vec::new();
712
713 for mismatch in &mismatches {
714 match mismatch {
715 TocMismatch::StaleEntry { entry } => {
716 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
717 }
718 TocMismatch::MissingEntry { expected } => {
719 details.push(format!(
720 "Missing entry: '{}' (line {})",
721 expected.text, expected.heading_line
722 ));
723 }
724 TocMismatch::TextMismatch { entry, expected } => {
725 details.push(format!(
726 "Text mismatch: TOC has '{}', heading is '{}'",
727 entry.text, expected.text
728 ));
729 }
730 TocMismatch::OrderMismatch {
731 entry,
732 expected_position,
733 } => {
734 details.push(format!(
735 "Order mismatch: '{}' should be at position {}",
736 entry.text, expected_position
737 ));
738 }
739 TocMismatch::IndentationMismatch {
740 entry,
741 actual_indent,
742 expected_indent,
743 ..
744 } => {
745 details.push(format!(
746 "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
747 entry.text, actual_indent, expected_indent
748 ));
749 }
750 }
751 }
752
753 let message = format!(
754 "Table of Contents does not match document headings: {}",
755 details.join("; ")
756 );
757
758 let regenerated = Self::keep_reached_anchors(&actual_entries, &expected_entries, &matching);
760 let new_toc = self.generate_toc(®enerated);
761 let fix_range = region.content_start..region.content_end;
762 let (stop_marker_line, _) = ctx.offset_to_line_col(region.content_end);
765 let in_blockquote = [region.start_line - 1, stop_marker_line]
766 .into_iter()
767 .any(|line| ctx.line_info(line).is_some_and(|info| info.blockquote.is_some()));
768
769 warnings.push(LintWarning {
772 rule_name: Some(self.name().to_string()),
773 message,
774 line: region.start_line,
775 column: 1,
776 end_line: stop_marker_line,
777 end_column: 1,
778 severity: Severity::Warning,
779 fix: (!in_blockquote).then(|| Fix::new(fix_range, new_toc)),
780 });
781 }
782
783 Ok(warnings)
784 }
785
786 fn fixes_from_diagnostics(&self) -> bool {
787 true
788 }
789
790 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
791 if self.should_skip(ctx) {
792 return Ok(ctx.content.to_string());
793 }
794 let warnings = self.check(ctx)?;
795 if warnings.is_empty() {
796 return Ok(ctx.content.to_string());
797 }
798 let warnings =
799 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
800 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
801 }
802
803 fn category(&self) -> RuleCategory {
804 RuleCategory::Other
805 }
806
807 fn as_any(&self) -> &dyn std::any::Any {
808 self
809 }
810
811 fn default_config_section(&self) -> Option<(String, toml::Value)> {
812 let value: toml::Value = toml::from_str(
813 r#"
814# Whether this rule is enabled (opt-in, disabled by default)
815enabled = false
816# Minimum heading level to include
817min-level = 2
818# Maximum heading level to include
819max-level = 4
820# Whether TOC order must match document order
821enforce-order = true
822# Indentation per nesting level (defaults to MD007's indent value)
823indent = 2
824"#,
825 )
826 .ok()?;
827 Some(("MD073".to_string(), value))
828 }
829
830 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
831 where
832 Self: Sized,
833 {
834 let mut rule = MD073TocValidation::default();
835 let mut indent_from_md073 = false;
836
837 if let Some(rule_config) = config.rules.get("MD073") {
838 if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
840 rule.enabled = enabled;
841 }
842
843 if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
845 rule.min_level = (min_level.clamp(1, 6)) as u8;
846 }
847
848 if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
850 rule.max_level = (max_level.clamp(1, 6)) as u8;
851 }
852
853 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
855 rule.enforce_order = enforce_order;
856 }
857
858 if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
860 rule.indent = (indent.clamp(1, 8)) as usize;
861 indent_from_md073 = true;
862 }
863 }
864
865 if !indent_from_md073
867 && let Some(md007_config) = config.rules.get("MD007")
868 && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
869 {
870 rule.indent = (indent.clamp(1, 8)) as usize;
871 }
872
873 Box::new(rule)
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::*;
880 use crate::config::MarkdownFlavor;
881 use regex::Regex;
882 use std::sync::LazyLock;
883
884 fn strip_code_spans(text: &str) -> String {
890 let chars: Vec<char> = text.chars().collect();
891 let len = chars.len();
892 let mut result = String::with_capacity(text.len());
893 let mut i = 0;
894
895 while i < len {
896 if chars[i] == '`' {
897 let open_start = i;
898 while i < len && chars[i] == '`' {
899 i += 1;
900 }
901 let backtick_count = i - open_start;
902
903 let content_start = i;
904 let mut found_close = false;
905 while i < len {
906 if chars[i] == '`' {
907 let close_start = i;
908 while i < len && chars[i] == '`' {
909 i += 1;
910 }
911 if i - close_start == backtick_count {
912 let content: String = chars[content_start..close_start].iter().collect();
913 let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
914 content[1..content.len() - 1].to_string()
915 } else {
916 content
917 };
918 result.push_str(&stripped);
919 found_close = true;
920 break;
921 }
922 } else {
923 i += 1;
924 }
925 }
926 if !found_close {
927 for _ in 0..backtick_count {
928 result.push('`');
929 }
930 let remaining: String = chars[content_start..].iter().collect();
931 result.push_str(&remaining);
932 break;
933 }
934 } else {
935 result.push(chars[i]);
936 i += 1;
937 }
938 }
939
940 result
941 }
942
943 static TEST_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
944 static TEST_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
945 static TEST_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
946 static TEST_ITALIC_UNDERSCORE: LazyLock<Regex> =
947 LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
948
949 fn strip_markdown_formatting(text: &str) -> String {
953 let mut result = strip_links_and_images(text);
954 result = strip_code_spans(&result);
955 result = TEST_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
956 result = TEST_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
957 result = TEST_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
958 result = TEST_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
959 result
960 }
961
962 fn create_ctx(content: &str) -> LintContext<'_> {
963 LintContext::new(content, MarkdownFlavor::Standard, None)
964 }
965
966 fn create_enabled_rule() -> MD073TocValidation {
968 MD073TocValidation {
969 enabled: true,
970 ..MD073TocValidation::default()
971 }
972 }
973
974 #[test]
977 fn test_detect_markers_basic() {
978 let rule = MD073TocValidation::new();
979 let content = r#"# Title
980
981<!-- toc -->
982
983- [Heading 1](#heading-1)
984
985<!-- tocstop -->
986
987## Heading 1
988
989Content here.
990"#;
991 let ctx = create_ctx(content);
992 let region = rule.detect_by_markers(&ctx);
993 assert!(region.is_some());
994 let region = region.unwrap();
995 assert_eq!(region.start_line, 4);
997 assert_eq!(region.end_line, 6);
998 }
999
1000 #[test]
1001 fn test_detect_markers_variations() {
1002 let rule = MD073TocValidation::new();
1003
1004 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
1006 let ctx1 = create_ctx(content1);
1007 assert!(rule.detect_by_markers(&ctx1).is_some());
1008
1009 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
1011 let ctx2 = create_ctx(content2);
1012 assert!(rule.detect_by_markers(&ctx2).is_some());
1013
1014 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
1016 let ctx3 = create_ctx(content3);
1017 assert!(rule.detect_by_markers(&ctx3).is_some());
1018 }
1019
1020 #[test]
1021 fn test_no_toc_region() {
1022 let rule = MD073TocValidation::new();
1023 let content = r#"# Title
1024
1025## Heading 1
1026
1027Content here.
1028
1029## Heading 2
1030
1031More content.
1032"#;
1033 let ctx = create_ctx(content);
1034 let region = rule.detect_toc_region(&ctx);
1035 assert!(region.is_none());
1036 }
1037
1038 #[test]
1041 fn test_toc_matches_headings() {
1042 let rule = create_enabled_rule();
1043 let content = r#"# Title
1044
1045<!-- toc -->
1046
1047- [Heading 1](#heading-1)
1048- [Heading 2](#heading-2)
1049
1050<!-- tocstop -->
1051
1052## Heading 1
1053
1054Content.
1055
1056## Heading 2
1057
1058More content.
1059"#;
1060 let ctx = create_ctx(content);
1061 let result = rule.check(&ctx).unwrap();
1062 assert!(result.is_empty(), "Expected no warnings for matching TOC");
1063 }
1064
1065 #[test]
1066 fn test_missing_entry() {
1067 let rule = create_enabled_rule();
1068 let content = r#"# Title
1069
1070<!-- toc -->
1071
1072- [Heading 1](#heading-1)
1073
1074<!-- tocstop -->
1075
1076## Heading 1
1077
1078Content.
1079
1080## Heading 2
1081
1082New heading not in TOC.
1083"#;
1084 let ctx = create_ctx(content);
1085 let result = rule.check(&ctx).unwrap();
1086 assert_eq!(result.len(), 1);
1087 assert!(result[0].message.contains("Missing entry"));
1088 assert!(result[0].message.contains("Heading 2"));
1089 }
1090
1091 #[test]
1092 fn test_stale_entry() {
1093 let rule = create_enabled_rule();
1094 let content = r#"# Title
1095
1096<!-- toc -->
1097
1098- [Heading 1](#heading-1)
1099- [Deleted Heading](#deleted-heading)
1100
1101<!-- tocstop -->
1102
1103## Heading 1
1104
1105Content.
1106"#;
1107 let ctx = create_ctx(content);
1108 let result = rule.check(&ctx).unwrap();
1109 assert_eq!(result.len(), 1);
1110 assert!(result[0].message.contains("Stale entry"));
1111 assert!(result[0].message.contains("Deleted Heading"));
1112 }
1113
1114 #[test]
1115 fn test_text_mismatch() {
1116 let rule = create_enabled_rule();
1117 let content = r#"# Title
1118
1119<!-- toc -->
1120
1121- [Old Name](#heading-1)
1122
1123<!-- tocstop -->
1124
1125## Heading 1
1126
1127Content.
1128"#;
1129 let ctx = create_ctx(content);
1130 let result = rule.check(&ctx).unwrap();
1131 assert_eq!(result.len(), 1);
1132 assert!(result[0].message.contains("Text mismatch"));
1133 }
1134
1135 #[test]
1138 fn test_min_level_excludes_h1() {
1139 let mut rule = MD073TocValidation::new();
1140 rule.min_level = 2;
1141
1142 let content = r#"<!-- toc -->
1143
1144<!-- tocstop -->
1145
1146# Should Be Excluded
1147
1148## Should Be Included
1149
1150Content.
1151"#;
1152 let ctx = create_ctx(content);
1153 let region = rule.detect_toc_region(&ctx).unwrap();
1154 let expected = rule.build_expected_toc(&ctx, ®ion);
1155
1156 assert_eq!(expected.len(), 1);
1157 assert_eq!(expected[0].text, "Should Be Included");
1158 }
1159
1160 #[test]
1161 fn test_max_level_excludes_h5_h6() {
1162 let mut rule = MD073TocValidation::new();
1163 rule.max_level = 4;
1164
1165 let content = r#"<!-- toc -->
1166
1167<!-- tocstop -->
1168
1169## Level 2
1170
1171### Level 3
1172
1173#### Level 4
1174
1175##### Level 5 Should Be Excluded
1176
1177###### Level 6 Should Be Excluded
1178"#;
1179 let ctx = create_ctx(content);
1180 let region = rule.detect_toc_region(&ctx).unwrap();
1181 let expected = rule.build_expected_toc(&ctx, ®ion);
1182
1183 assert_eq!(expected.len(), 3);
1184 assert!(expected.iter().all(|e| e.level <= 4));
1185 }
1186
1187 #[test]
1190 fn test_fix_adds_missing_entry() {
1191 let rule = MD073TocValidation::new();
1192 let content = r#"# Title
1193
1194<!-- toc -->
1195
1196- [Heading 1](#heading-1)
1197
1198<!-- tocstop -->
1199
1200## Heading 1
1201
1202Content.
1203
1204## Heading 2
1205
1206New heading.
1207"#;
1208 let ctx = create_ctx(content);
1209 let fixed = rule.fix(&ctx).unwrap();
1210 assert!(fixed.contains("- [Heading 2](#heading-2)"));
1211 }
1212
1213 #[test]
1214 fn test_fix_removes_stale_entry() {
1215 let rule = MD073TocValidation::new();
1216 let content = r#"# Title
1217
1218<!-- toc -->
1219
1220- [Heading 1](#heading-1)
1221- [Deleted](#deleted)
1222
1223<!-- tocstop -->
1224
1225## Heading 1
1226
1227Content.
1228"#;
1229 let ctx = create_ctx(content);
1230 let fixed = rule.fix(&ctx).unwrap();
1231 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1232 assert!(!fixed.contains("Deleted"));
1233 }
1234
1235 #[test]
1236 fn test_fix_idempotent() {
1237 let rule = MD073TocValidation::new();
1238 let content = r#"# Title
1239
1240<!-- toc -->
1241
1242- [Heading 1](#heading-1)
1243- [Heading 2](#heading-2)
1244
1245<!-- tocstop -->
1246
1247## Heading 1
1248
1249Content.
1250
1251## Heading 2
1252
1253More.
1254"#;
1255 let ctx = create_ctx(content);
1256 let fixed1 = rule.fix(&ctx).unwrap();
1257 let ctx2 = create_ctx(&fixed1);
1258 let fixed2 = rule.fix(&ctx2).unwrap();
1259
1260 assert_eq!(fixed1, fixed2);
1262 }
1263
1264 #[test]
1265 fn test_fix_preserves_markers() {
1266 let rule = MD073TocValidation::new();
1267 let content = r#"# Title
1268
1269<!-- toc -->
1270
1271Old TOC content.
1272
1273<!-- tocstop -->
1274
1275## New Heading
1276
1277Content.
1278"#;
1279 let ctx = create_ctx(content);
1280 let fixed = rule.fix(&ctx).unwrap();
1281
1282 assert!(fixed.contains("<!-- toc -->"));
1284 assert!(fixed.contains("<!-- tocstop -->"));
1285 assert!(fixed.contains("- [New Heading](#new-heading)"));
1287 }
1288
1289 #[test]
1290 fn test_fix_requires_markers() {
1291 let rule = create_enabled_rule();
1292
1293 let content_no_markers = r#"# Title
1295
1296## Heading 1
1297
1298Content.
1299"#;
1300 let ctx = create_ctx(content_no_markers);
1301 let fixed = rule.fix(&ctx).unwrap();
1302 assert_eq!(fixed, content_no_markers);
1303
1304 let content_markers = r#"# Title
1306
1307<!-- toc -->
1308
1309- [Old Entry](#old-entry)
1310
1311<!-- tocstop -->
1312
1313## Heading 1
1314
1315Content.
1316"#;
1317 let ctx = create_ctx(content_markers);
1318 let fixed = rule.fix(&ctx).unwrap();
1319 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1320 assert!(!fixed.contains("Old Entry"));
1321 }
1322
1323 #[test]
1326 fn test_duplicate_heading_anchors() {
1327 let rule = MD073TocValidation::new();
1328 let content = r#"# Title
1329
1330<!-- toc -->
1331
1332<!-- tocstop -->
1333
1334## Duplicate
1335
1336Content.
1337
1338## Duplicate
1339
1340More content.
1341
1342## Duplicate
1343
1344Even more.
1345"#;
1346 let ctx = create_ctx(content);
1347 let region = rule.detect_toc_region(&ctx).unwrap();
1348 let expected = rule.build_expected_toc(&ctx, ®ion);
1349
1350 assert_eq!(expected.len(), 3);
1351 assert_eq!(expected[0].anchor, "duplicate");
1352 assert_eq!(expected[1].anchor, "duplicate-1");
1353 assert_eq!(expected[2].anchor, "duplicate-2");
1354 }
1355
1356 #[test]
1359 fn test_headings_in_code_blocks_ignored() {
1360 let rule = create_enabled_rule();
1361 let content = r#"# Title
1362
1363<!-- toc -->
1364
1365- [Real Heading](#real-heading)
1366
1367<!-- tocstop -->
1368
1369## Real Heading
1370
1371```markdown
1372## Fake Heading In Code
1373```
1374
1375Content.
1376"#;
1377 let ctx = create_ctx(content);
1378 let result = rule.check(&ctx).unwrap();
1379 assert!(result.is_empty(), "Should not report fake heading in code block");
1380 }
1381
1382 #[test]
1383 fn test_empty_toc_region() {
1384 let rule = create_enabled_rule();
1385 let content = r#"# Title
1386
1387<!-- toc -->
1388<!-- tocstop -->
1389
1390## Heading 1
1391
1392Content.
1393"#;
1394 let ctx = create_ctx(content);
1395 let result = rule.check(&ctx).unwrap();
1396 assert_eq!(result.len(), 1);
1397 assert!(result[0].message.contains("Missing entry"));
1398 }
1399
1400 #[test]
1401 fn test_nested_indentation() {
1402 let rule = create_enabled_rule();
1403
1404 let content = r#"<!-- toc -->
1405
1406<!-- tocstop -->
1407
1408## Level 2
1409
1410### Level 3
1411
1412#### Level 4
1413
1414## Another Level 2
1415"#;
1416 let ctx = create_ctx(content);
1417 let region = rule.detect_toc_region(&ctx).unwrap();
1418 let expected = rule.build_expected_toc(&ctx, ®ion);
1419 let toc = rule.generate_toc(&expected);
1420
1421 assert!(toc.contains("- [Level 2](#level-2)"));
1423 assert!(toc.contains(" - [Level 3](#level-3)"));
1424 assert!(toc.contains(" - [Level 4](#level-4)"));
1425 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1426 }
1427
1428 #[test]
1431 fn test_indentation_mismatch_detected() {
1432 let rule = create_enabled_rule();
1433 let content = r#"<!-- toc -->
1435- [Hello](#hello)
1436- [Another](#another)
1437- [Heading](#heading)
1438<!-- tocstop -->
1439
1440## Hello
1441
1442### Another
1443
1444## Heading
1445"#;
1446 let ctx = create_ctx(content);
1447 let result = rule.check(&ctx).unwrap();
1448 assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1450 assert!(
1451 result[0].message.contains("Indentation mismatch"),
1452 "Message should mention indentation: {}",
1453 result[0].message
1454 );
1455 assert!(
1456 result[0].message.contains("Another"),
1457 "Message should mention the entry: {}",
1458 result[0].message
1459 );
1460 }
1461
1462 #[test]
1463 fn test_indentation_mismatch_fixed() {
1464 let rule = create_enabled_rule();
1465 let content = r#"<!-- toc -->
1467- [Hello](#hello)
1468- [Another](#another)
1469- [Heading](#heading)
1470<!-- tocstop -->
1471
1472## Hello
1473
1474### Another
1475
1476## Heading
1477"#;
1478 let ctx = create_ctx(content);
1479 let fixed = rule.fix(&ctx).unwrap();
1480 assert!(fixed.contains("- [Hello](#hello)"));
1482 assert!(fixed.contains(" - [Another](#another)")); assert!(fixed.contains("- [Heading](#heading)"));
1484 }
1485
1486 #[test]
1487 fn test_no_indentation_mismatch_when_correct() {
1488 let rule = create_enabled_rule();
1489 let content = r#"<!-- toc -->
1491- [Hello](#hello)
1492 - [Another](#another)
1493- [Heading](#heading)
1494<!-- tocstop -->
1495
1496## Hello
1497
1498### Another
1499
1500## Heading
1501"#;
1502 let ctx = create_ctx(content);
1503 let result = rule.check(&ctx).unwrap();
1504 assert!(result.is_empty(), "Should not report issues: {result:?}");
1506 }
1507
1508 #[test]
1511 fn test_order_mismatch_detected() {
1512 let rule = create_enabled_rule();
1513 let content = r#"# Title
1514
1515<!-- toc -->
1516
1517- [Section B](#section-b)
1518- [Section A](#section-a)
1519
1520<!-- tocstop -->
1521
1522## Section A
1523
1524Content A.
1525
1526## Section B
1527
1528Content B.
1529"#;
1530 let ctx = create_ctx(content);
1531 let result = rule.check(&ctx).unwrap();
1532 assert!(!result.is_empty(), "Should detect order mismatch");
1535 }
1536
1537 #[test]
1538 fn test_order_mismatch_ignored_when_disabled() {
1539 let mut rule = create_enabled_rule();
1540 rule.enforce_order = false;
1541 let content = r#"# Title
1542
1543<!-- toc -->
1544
1545- [Section B](#section-b)
1546- [Section A](#section-a)
1547
1548<!-- tocstop -->
1549
1550## Section A
1551
1552Content A.
1553
1554## Section B
1555
1556Content B.
1557"#;
1558 let ctx = create_ctx(content);
1559 let result = rule.check(&ctx).unwrap();
1560 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1562 }
1563
1564 #[test]
1567 fn test_unicode_headings() {
1568 let rule = create_enabled_rule();
1569 let content = r#"# Title
1570
1571<!-- toc -->
1572
1573- [日本語の見出し](#日本語の見出し)
1574- [Émojis 🎉](#émojis-)
1575
1576<!-- tocstop -->
1577
1578## 日本語の見出し
1579
1580Japanese content.
1581
1582## Émojis 🎉
1583
1584Content with emojis.
1585"#;
1586 let ctx = create_ctx(content);
1587 let result = rule.check(&ctx).unwrap();
1588 assert!(result.is_empty(), "Should handle unicode headings");
1590 }
1591
1592 #[test]
1593 fn test_special_characters_in_headings() {
1594 let rule = create_enabled_rule();
1595 let content = r#"# Title
1596
1597<!-- toc -->
1598
1599- [What's New?](#whats-new)
1600- [C++ Guide](#c-guide)
1601
1602<!-- tocstop -->
1603
1604## What's New?
1605
1606News content.
1607
1608## C++ Guide
1609
1610C++ content.
1611"#;
1612 let ctx = create_ctx(content);
1613 let result = rule.check(&ctx).unwrap();
1614 assert!(result.is_empty(), "Should handle special characters");
1615 }
1616
1617 #[test]
1618 fn test_code_spans_in_headings() {
1619 let rule = create_enabled_rule();
1620 let content = r#"# Title
1621
1622<!-- toc -->
1623
1624- [`check [PATHS...]`](#check-paths)
1625
1626<!-- tocstop -->
1627
1628## `check [PATHS...]`
1629
1630Command documentation.
1631"#;
1632 let ctx = create_ctx(content);
1633 let result = rule.check(&ctx).unwrap();
1634 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1635 }
1636
1637 #[test]
1640 fn test_from_config_defaults() {
1641 let config = crate::config::Config::default();
1642 let rule = MD073TocValidation::from_config(&config);
1643 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1644
1645 assert_eq!(rule.min_level, 2);
1646 assert_eq!(rule.max_level, 4);
1647 assert!(rule.enforce_order);
1648 assert_eq!(rule.indent, 2);
1649 }
1650
1651 #[test]
1652 fn test_indent_from_md007_config() {
1653 use crate::config::{Config, RuleConfig};
1654 use std::collections::BTreeMap;
1655
1656 let mut config = Config::default();
1657
1658 let mut md007_values = BTreeMap::new();
1660 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1661 config.rules.insert(
1662 "MD007".to_string(),
1663 RuleConfig {
1664 severity: None,
1665 values: md007_values,
1666 },
1667 );
1668
1669 let rule = MD073TocValidation::from_config(&config);
1670 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1671
1672 assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1673 }
1674
1675 #[test]
1676 fn test_indent_md073_overrides_md007() {
1677 use crate::config::{Config, RuleConfig};
1678 use std::collections::BTreeMap;
1679
1680 let mut config = Config::default();
1681
1682 let mut md007_values = BTreeMap::new();
1684 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1685 config.rules.insert(
1686 "MD007".to_string(),
1687 RuleConfig {
1688 severity: None,
1689 values: md007_values,
1690 },
1691 );
1692
1693 let mut md073_values = BTreeMap::new();
1695 md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1696 md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1697 config.rules.insert(
1698 "MD073".to_string(),
1699 RuleConfig {
1700 severity: None,
1701 values: md073_values,
1702 },
1703 );
1704
1705 let rule = MD073TocValidation::from_config(&config);
1706 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1707
1708 assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1709 }
1710
1711 #[test]
1712 fn test_generate_toc_with_4_space_indent() {
1713 let mut rule = create_enabled_rule();
1714 rule.indent = 4;
1715
1716 let content = r#"<!-- toc -->
1717
1718<!-- tocstop -->
1719
1720## Level 2
1721
1722### Level 3
1723
1724#### Level 4
1725
1726## Another Level 2
1727"#;
1728 let ctx = create_ctx(content);
1729 let region = rule.detect_toc_region(&ctx).unwrap();
1730 let expected = rule.build_expected_toc(&ctx, ®ion);
1731 let toc = rule.generate_toc(&expected);
1732
1733 assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1738 assert!(
1739 toc.contains(" - [Level 3](#level-3)"),
1740 "Level 3 should have 4-space indent"
1741 );
1742 assert!(
1743 toc.contains(" - [Level 4](#level-4)"),
1744 "Level 4 should have 8-space indent"
1745 );
1746 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1747 }
1748
1749 #[test]
1750 fn test_validate_toc_with_4_space_indent() {
1751 let mut rule = create_enabled_rule();
1752 rule.indent = 4;
1753
1754 let content = r#"<!-- toc -->
1756- [Hello](#hello)
1757 - [Another](#another)
1758- [Heading](#heading)
1759<!-- tocstop -->
1760
1761## Hello
1762
1763### Another
1764
1765## Heading
1766"#;
1767 let ctx = create_ctx(content);
1768 let result = rule.check(&ctx).unwrap();
1769 assert!(
1770 result.is_empty(),
1771 "Should accept 4-space indent when configured: {result:?}"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_validate_toc_wrong_indent_with_4_space_config() {
1777 let mut rule = create_enabled_rule();
1778 rule.indent = 4;
1779
1780 let content = r#"<!-- toc -->
1782- [Hello](#hello)
1783 - [Another](#another)
1784- [Heading](#heading)
1785<!-- tocstop -->
1786
1787## Hello
1788
1789### Another
1790
1791## Heading
1792"#;
1793 let ctx = create_ctx(content);
1794 let result = rule.check(&ctx).unwrap();
1795 assert_eq!(result.len(), 1, "Should detect wrong indent");
1796 assert!(
1797 result[0].message.contains("Indentation mismatch"),
1798 "Should report indentation mismatch: {}",
1799 result[0].message
1800 );
1801 assert!(
1802 result[0].message.contains("expected 4 spaces"),
1803 "Should mention expected 4 spaces: {}",
1804 result[0].message
1805 );
1806 }
1807
1808 #[test]
1811 fn test_strip_markdown_formatting_link() {
1812 let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1813 assert_eq!(result, "Tool: terminal");
1814 }
1815
1816 #[test]
1817 fn test_strip_markdown_formatting_bold() {
1818 let result = strip_markdown_formatting("This is **bold** text");
1819 assert_eq!(result, "This is bold text");
1820
1821 let result = strip_markdown_formatting("This is __bold__ text");
1822 assert_eq!(result, "This is bold text");
1823 }
1824
1825 #[test]
1826 fn test_strip_markdown_formatting_italic() {
1827 let result = strip_markdown_formatting("This is *italic* text");
1828 assert_eq!(result, "This is italic text");
1829
1830 let result = strip_markdown_formatting("This is _italic_ text");
1831 assert_eq!(result, "This is italic text");
1832 }
1833
1834 #[test]
1835 fn test_strip_markdown_formatting_code_span() {
1836 let result = strip_markdown_formatting("Use the `format` function");
1837 assert_eq!(result, "Use the format function");
1838 }
1839
1840 #[test]
1841 fn test_strip_markdown_formatting_image() {
1842 let result = strip_markdown_formatting("See  for details");
1843 assert_eq!(result, "See logo for details");
1844 }
1845
1846 #[test]
1847 fn test_strip_markdown_formatting_reference_link() {
1848 let result = strip_markdown_formatting("See [documentation][docs] for details");
1849 assert_eq!(result, "See documentation for details");
1850 }
1851
1852 #[test]
1853 fn test_strip_markdown_formatting_combined() {
1854 let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1856 assert_eq!(result, "Tool: terminal");
1857 }
1858
1859 #[test]
1860 fn test_toc_with_link_in_heading_matches_stripped_text() {
1861 let rule = create_enabled_rule();
1862
1863 let content = r#"# Title
1865
1866<!-- toc -->
1867
1868- [Tool: terminal](#tool-terminal)
1869
1870<!-- tocstop -->
1871
1872## Tool: [terminal](https://example.com)
1873
1874Content here.
1875"#;
1876 let ctx = create_ctx(content);
1877 let result = rule.check(&ctx).unwrap();
1878 assert!(
1879 result.is_empty(),
1880 "Stripped heading text should match TOC entry: {result:?}"
1881 );
1882 }
1883
1884 #[test]
1885 fn test_toc_with_simplified_text_still_mismatches() {
1886 let rule = create_enabled_rule();
1887
1888 let content = r#"# Title
1890
1891<!-- toc -->
1892
1893- [terminal](#tool-terminal)
1894
1895<!-- tocstop -->
1896
1897## Tool: [terminal](https://example.com)
1898
1899Content here.
1900"#;
1901 let ctx = create_ctx(content);
1902 let result = rule.check(&ctx).unwrap();
1903 assert_eq!(result.len(), 1, "Should report text mismatch");
1904 assert!(result[0].message.contains("Text mismatch"));
1905 }
1906
1907 #[test]
1908 fn test_fix_generates_stripped_toc_entries() {
1909 let rule = MD073TocValidation::new();
1910 let content = r#"# Title
1911
1912<!-- toc -->
1913
1914<!-- tocstop -->
1915
1916## Tool: [busybox](https://www.busybox.net/)
1917
1918Content.
1919
1920## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1921
1922More content.
1923"#;
1924 let ctx = create_ctx(content);
1925 let fixed = rule.fix(&ctx).unwrap();
1926
1927 assert!(
1929 fixed.contains("- [Tool: busybox](#tool-busybox)"),
1930 "TOC entry should have stripped link text"
1931 );
1932 assert!(
1933 fixed.contains("- [Tool: mount](#tool-mount)"),
1934 "TOC entry should have stripped link text"
1935 );
1936 let toc_start = fixed.find("<!-- toc -->").unwrap();
1939 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1940 let toc_content = &fixed[toc_start..toc_end];
1941 assert!(
1942 !toc_content.contains("busybox.net"),
1943 "TOC should not contain URLs: {toc_content}"
1944 );
1945 assert!(
1946 !toc_content.contains("wikipedia.org"),
1947 "TOC should not contain URLs: {toc_content}"
1948 );
1949 }
1950
1951 #[test]
1952 fn test_fix_with_bold_in_heading() {
1953 let rule = MD073TocValidation::new();
1954 let content = r#"# Title
1955
1956<!-- toc -->
1957
1958<!-- tocstop -->
1959
1960## **Important** Section
1961
1962Content.
1963"#;
1964 let ctx = create_ctx(content);
1965 let fixed = rule.fix(&ctx).unwrap();
1966
1967 assert!(fixed.contains("- [**Important** Section](#important-section)"));
1969 }
1970
1971 #[test]
1972 fn test_fix_with_code_in_heading() {
1973 let rule = MD073TocValidation::new();
1974 let content = r#"# Title
1975
1976<!-- toc -->
1977
1978<!-- tocstop -->
1979
1980## Using `async` Functions
1981
1982Content.
1983"#;
1984 let ctx = create_ctx(content);
1985 let fixed = rule.fix(&ctx).unwrap();
1986
1987 assert!(fixed.contains("- [Using `async` Functions](#using-async-functions)"));
1989 }
1990
1991 #[test]
1994 fn test_custom_anchor_id_respected() {
1995 let rule = create_enabled_rule();
1996 let content = r#"# Title
1997
1998<!-- toc -->
1999
2000- [My Section](#my-custom-anchor)
2001
2002<!-- tocstop -->
2003
2004## My Section {#my-custom-anchor}
2005
2006Content here.
2007"#;
2008 let ctx = create_ctx(content);
2009 let result = rule.check(&ctx).unwrap();
2010 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
2011 }
2012
2013 #[test]
2014 fn test_custom_anchor_id_in_generated_toc() {
2015 let rule = create_enabled_rule();
2016 let content = r#"# Title
2017
2018<!-- toc -->
2019
2020<!-- tocstop -->
2021
2022## First Section {#custom-first}
2023
2024Content.
2025
2026## Second Section {#another-custom}
2027
2028More content.
2029"#;
2030 let ctx = create_ctx(content);
2031 let fixed = rule.fix(&ctx).unwrap();
2032 assert!(fixed.contains("- [First Section](#custom-first)"));
2033 assert!(fixed.contains("- [Second Section](#another-custom)"));
2034 }
2035
2036 #[test]
2037 fn test_mixed_custom_and_generated_anchors() {
2038 let rule = create_enabled_rule();
2039 let content = r#"# Title
2040
2041<!-- toc -->
2042
2043- [Custom Section](#my-id)
2044- [Normal Section](#normal-section)
2045
2046<!-- tocstop -->
2047
2048## Custom Section {#my-id}
2049
2050Content.
2051
2052## Normal Section
2053
2054More content.
2055"#;
2056 let ctx = create_ctx(content);
2057 let result = rule.check(&ctx).unwrap();
2058 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
2059 }
2060
2061 #[test]
2064 fn test_github_anchor_style() {
2065 let rule = create_enabled_rule();
2066
2067 let content = r#"<!-- toc -->
2068
2069<!-- tocstop -->
2070
2071## Test_With_Underscores
2072
2073Content.
2074"#;
2075 let ctx = create_ctx(content);
2076 let region = rule.detect_toc_region(&ctx).unwrap();
2077 let expected = rule.build_expected_toc(&ctx, ®ion);
2078
2079 assert_eq!(expected[0].anchor, "test_with_underscores");
2081 }
2082
2083 #[test]
2086 fn test_stress_many_headings() {
2087 let rule = create_enabled_rule();
2088
2089 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2091
2092 for i in 1..=150 {
2093 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
2094 }
2095
2096 let ctx = create_ctx(&content);
2097
2098 let result = rule.check(&ctx).unwrap();
2100
2101 assert_eq!(result.len(), 1, "Should report single warning for TOC");
2103 assert!(result[0].message.contains("Missing entry"));
2104
2105 let fixed = rule.fix(&ctx).unwrap();
2107 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
2108 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
2109 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
2110 }
2111
2112 #[test]
2113 fn test_stress_deeply_nested() {
2114 let rule = create_enabled_rule();
2115 let content = r#"# Title
2116
2117<!-- toc -->
2118
2119<!-- tocstop -->
2120
2121## Level 2 A
2122
2123### Level 3 A
2124
2125#### Level 4 A
2126
2127## Level 2 B
2128
2129### Level 3 B
2130
2131#### Level 4 B
2132
2133## Level 2 C
2134
2135### Level 3 C
2136
2137#### Level 4 C
2138
2139## Level 2 D
2140
2141### Level 3 D
2142
2143#### Level 4 D
2144"#;
2145 let ctx = create_ctx(content);
2146 let fixed = rule.fix(&ctx).unwrap();
2147
2148 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
2150 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
2151 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
2152 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
2153 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
2154 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
2155 }
2156
2157 #[test]
2160 fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
2161 let rule = create_enabled_rule();
2162 let content = r#"# Document
2166
2167<!-- toc -->
2168
2169- [Example](#example)
2170- [Another](#another)
2171- [Example](#example)
2172
2173<!-- tocstop -->
2174
2175## Example
2176First.
2177
2178## Another
2179Middle.
2180
2181## Example
2182Second.
2183"#;
2184 let ctx = create_ctx(content);
2185 let result = rule.check(&ctx).unwrap();
2186
2187 assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
2190 assert!(
2191 result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
2192 "Should report missing or stale entries for duplicate anchors. Got: {}",
2193 result[0].message
2194 );
2195 }
2196
2197 #[test]
2200 fn test_strip_double_backtick_code_span() {
2201 let result = strip_markdown_formatting("Using ``code with ` backtick``");
2203 assert_eq!(
2204 result, "Using code with ` backtick",
2205 "Should strip double-backtick code spans"
2206 );
2207 }
2208
2209 #[test]
2210 fn test_strip_triple_backtick_code_span() {
2211 let result = strip_markdown_formatting("Using ```code with `` backticks```");
2213 assert_eq!(
2214 result, "Using code with `` backticks",
2215 "Should strip triple-backtick code spans"
2216 );
2217 }
2218
2219 #[test]
2220 fn test_toc_with_double_backtick_heading() {
2221 let rule = create_enabled_rule();
2222 let content = r#"# Title
2225
2226<!-- toc -->
2227
2228<!-- tocstop -->
2229
2230## Using ``code with ` backtick``
2231
2232Content here.
2233"#;
2234 let ctx = create_ctx(content);
2235 let fixed = rule.fix(&ctx).unwrap();
2238
2239 let toc_start = fixed.find("<!-- toc -->").unwrap();
2242 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2243 let toc_content = &fixed[toc_start..toc_end];
2244 assert!(
2245 toc_content.contains("``code with ` backtick``"),
2246 "Fix should preserve double-backtick code span in TOC display text. Got: {toc_content}"
2247 );
2248
2249 let ctx2 = create_ctx(&fixed);
2251 let result = rule.check(&ctx2).unwrap();
2252 assert!(
2253 result.is_empty(),
2254 "check() should not warn on fixed output. Warnings: {result:?}"
2255 );
2256 }
2257
2258 #[test]
2259 fn test_stress_many_duplicates() {
2260 let rule = create_enabled_rule();
2261
2262 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2264 for _ in 0..50 {
2265 content.push_str("## FAQ\n\nContent.\n\n");
2266 }
2267
2268 let ctx = create_ctx(&content);
2269 let region = rule.detect_toc_region(&ctx).unwrap();
2270 let expected = rule.build_expected_toc(&ctx, ®ion);
2271
2272 assert_eq!(expected.len(), 50);
2274 assert_eq!(expected[0].anchor, "faq");
2275 assert_eq!(expected[1].anchor, "faq-1");
2276 assert_eq!(expected[49].anchor, "faq-49");
2277 }
2278
2279 #[test]
2282 fn test_roundtrip_check_and_fix_alignment() {
2283 let rule = create_enabled_rule();
2284
2285 let inputs = [
2286 "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2288 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2290 "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2292 "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2294 ];
2295
2296 for input in &inputs {
2297 let ctx = create_ctx(input);
2298 let fixed = rule.fix(&ctx).unwrap();
2299
2300 let ctx2 = create_ctx(&fixed);
2302 let fixed_twice = rule.fix(&ctx2).unwrap();
2303 assert_eq!(
2304 fixed, fixed_twice,
2305 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
2306 );
2307
2308 let warnings_after = rule.check(&ctx2).unwrap();
2310 assert!(
2311 warnings_after.is_empty(),
2312 "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2313 );
2314 }
2315 }
2316
2317 #[test]
2320 fn test_no_mismatch_preserves_content() {
2321 let rule = create_enabled_rule();
2322
2323 let content = "# Title\n\n<!-- toc -->\n- [First Section](#first-section)\n- [Second Section](#second-section)\n<!-- tocstop -->\n\n## First Section\n\ntext\n\n## Second Section\n\ntext\n";
2324 let ctx = create_ctx(content);
2325
2326 let warnings = rule.check(&ctx).unwrap();
2327 assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2328
2329 let fixed = rule.fix(&ctx).unwrap();
2330 assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2331 }
2332
2333 #[test]
2335 fn test_inline_disable_preserves_toc() {
2336 let rule = create_enabled_rule();
2337
2338 let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2340 let ctx = create_ctx(content);
2341
2342 let fixed = rule.fix(&ctx).unwrap();
2343 assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2344 }
2345
2346 #[test]
2349 fn test_warning_range_ends_at_stop_marker_line() {
2350 let rule = create_enabled_rule();
2351 let content = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha)\n- [Stale](#stale)\n<!-- tocstop -->\n\n## Alpha\n";
2352 let ctx = create_ctx(content);
2353
2354 let warnings = rule.check(&ctx).unwrap();
2355 assert_eq!(warnings.len(), 1);
2356 let w = &warnings[0];
2357 assert_eq!((w.line, w.column, w.end_line, w.end_column), (4, 1, 6, 1));
2358 assert_eq!(w.covered_lines(), 4..=5);
2359 }
2360
2361 #[test]
2364 fn test_disable_line_on_last_toc_entry_suppresses() {
2365 let content = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha)\n- [Stale](#stale) <!-- rumdl-disable-line MD073 -->\n<!-- tocstop -->\n\n## Alpha\n\n## Beta\n";
2366 let rules: Vec<Box<dyn Rule>> = vec![Box::new(create_enabled_rule())];
2367
2368 let warnings = crate::lint(content, &rules, false, MarkdownFlavor::Standard, None, None).unwrap();
2369 assert!(
2370 warnings.is_empty(),
2371 "disable-line on the last entry must suppress: {warnings:?}"
2372 );
2373
2374 let fixed = create_enabled_rule().fix(&create_ctx(content)).unwrap();
2375 assert_eq!(fixed, content, "a suppressed TOC must not be rewritten");
2376 }
2377
2378 #[test]
2384 fn test_fix_code_ticks_preserved_in_toc_display_text() {
2385 let rule = MD073TocValidation::new();
2386 let content = r#"# Title
2387
2388<!-- toc -->
2389
2390<!-- tocstop -->
2391
2392### `my header`
2393
2394Content.
2395"#;
2396 let ctx = create_ctx(content);
2397 let fixed = rule.fix(&ctx).unwrap();
2398
2399 assert!(
2400 fixed.contains("- [`my header`](#my-header)"),
2401 "Code ticks must be preserved in TOC display text. Got: {fixed}"
2402 );
2403 }
2404
2405 #[test]
2407 fn test_validate_toc_with_code_ticks_is_valid() {
2408 let rule = create_enabled_rule();
2409 let content = r#"# Title
2410
2411<!-- toc -->
2412
2413- [`my header`](#my-header)
2414
2415<!-- tocstop -->
2416
2417## `my header`
2418
2419Content.
2420"#;
2421 let ctx = create_ctx(content);
2422 let result = rule.check(&ctx).unwrap();
2423 assert!(
2424 result.is_empty(),
2425 "A TOC entry with preserved code ticks should be accepted as valid: {result:?}"
2426 );
2427 }
2428
2429 #[test]
2433 fn test_fix_emphasis_preserved_in_toc_display_text() {
2434 let rule = MD073TocValidation::new();
2435 let content = r#"# Title
2436
2437<!-- toc -->
2438
2439<!-- tocstop -->
2440
2441## **bold** and *italic*
2442
2443Content.
2444"#;
2445 let ctx = create_ctx(content);
2446 let fixed = rule.fix(&ctx).unwrap();
2447
2448 assert!(
2449 fixed.contains("- [**bold** and *italic*](#bold-and-italic)"),
2450 "Emphasis markers must be preserved in TOC display text. Got: {fixed}"
2451 );
2452 }
2453
2454 #[test]
2457 fn test_fix_link_in_heading_is_stripped() {
2458 let rule = MD073TocValidation::new();
2459 let content = r#"# Title
2460
2461<!-- toc -->
2462
2463<!-- tocstop -->
2464
2465## See [docs](http://example.com) for details
2466
2467Content.
2468"#;
2469 let ctx = create_ctx(content);
2470 let fixed = rule.fix(&ctx).unwrap();
2471
2472 assert!(
2473 fixed.contains("- [See docs for details](#see-docs-for-details)"),
2474 "Link must be stripped from TOC display text. Got: {fixed}"
2475 );
2476 let toc_start = fixed.find("<!-- toc -->").unwrap();
2478 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2479 let toc_content = &fixed[toc_start..toc_end];
2480 assert!(
2481 !toc_content.contains("http://example.com"),
2482 "TOC should not contain link URL: {toc_content}"
2483 );
2484 }
2485
2486 #[test]
2488 fn test_fix_image_in_heading_is_stripped() {
2489 let rule = MD073TocValidation::new();
2490 let content = r#"# Title
2491
2492<!-- toc -->
2493
2494<!-- tocstop -->
2495
2496## Section  Title
2497
2498Content.
2499"#;
2500 let ctx = create_ctx(content);
2501 let fixed = rule.fix(&ctx).unwrap();
2502
2503 assert!(
2504 fixed.contains("- [Section icon Title](#section-icon-title)"),
2505 "Image must be stripped from TOC display text. Got: {fixed}"
2506 );
2507 }
2508
2509 #[test]
2512 fn test_fix_idempotent_with_inline_formatting() {
2513 let rule = MD073TocValidation::new();
2514 let content = r#"# Title
2515
2516<!-- toc -->
2517
2518<!-- tocstop -->
2519
2520## `code` heading
2521
2522### **bold** heading
2523
2524## See [link](http://x.com)
2525
2526"#;
2527 let ctx = create_ctx(content);
2528 let fixed1 = rule.fix(&ctx).unwrap();
2529 let ctx2 = create_ctx(&fixed1);
2530 let fixed2 = rule.fix(&ctx2).unwrap();
2531
2532 assert_eq!(fixed1, fixed2, "fix() must be idempotent for inline-formatted headings");
2533
2534 let warnings = rule.check(&ctx2).unwrap();
2536 assert!(
2537 warnings.is_empty(),
2538 "check() must not warn after fix() for inline-formatted headings: {warnings:?}"
2539 );
2540 }
2541
2542 #[test]
2545 fn test_link_inside_code_span_preserved_in_toc() {
2546 let rule = MD073TocValidation::new();
2547 let content = r#"# Title
2548
2549<!-- toc -->
2550
2551<!-- tocstop -->
2552
2553## Use `[foo](bar)` syntax
2554
2555Content.
2556"#;
2557 let ctx = create_ctx(content);
2558 let fixed = rule.fix(&ctx).unwrap();
2559
2560 let toc_start = fixed.find("<!-- toc -->").unwrap();
2566 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2567 let toc_content = &fixed[toc_start..toc_end];
2568 assert!(
2569 toc_content.contains("Use `[foo](bar)` syntax"),
2570 "Link-like text inside code span must be preserved in TOC display text. Got: {toc_content}"
2571 );
2572 assert!(
2574 !toc_content.contains("http://"),
2575 "Real links (outside code spans) should be stripped: {toc_content}"
2576 );
2577 }
2578
2579 fn generated_toc(content: &str) -> String {
2583 let rule = create_enabled_rule();
2584 let ctx = create_ctx(content);
2585 let fixed = rule.fix(&ctx).unwrap();
2586 let start = fixed.find("<!-- toc -->").unwrap() + "<!-- toc -->".len();
2587 let end = fixed.find("<!-- tocstop -->").unwrap();
2588 fixed[start..end].trim().to_string()
2589 }
2590
2591 fn check_toc(content: &str) -> Vec<LintWarning> {
2592 let rule = create_enabled_rule();
2593 let ctx = create_ctx(content);
2594 rule.check(&ctx).unwrap()
2595 }
2596
2597 #[test]
2598 fn test_toc_may_target_the_generated_slug_of_a_heading_with_an_html_anchor() {
2599 for anchor in ["cheat-sheets", "cheatsheets"] {
2602 let content = format!(
2603 "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\nContent.\n"
2604 );
2605 let result = check_toc(&content);
2606 assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2607 }
2608 }
2609
2610 #[test]
2611 fn test_every_anchor_element_on_a_heading_is_a_valid_target() {
2612 for anchor in ["old", "older", "foo"] {
2613 let content = format!(
2614 "# Title\n\n<!-- toc -->\n\n- [Foo](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"old\"></a><a id=\"older\"></a>Foo\n"
2615 );
2616 let result = check_toc(&content);
2617 assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2618 }
2619 }
2620
2621 #[test]
2622 fn test_generated_toc_prefers_the_explicit_html_anchor() {
2623 let toc =
2624 generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n");
2625 assert_eq!(toc, "- [Cheat Sheets](#cheatsheets)");
2626 }
2627
2628 #[test]
2629 fn test_regeneration_keeps_an_existing_anchor_that_reaches_its_heading() {
2630 let content = "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#cheat-sheets)\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\n## Other\n";
2633 assert_eq!(
2634 generated_toc(content),
2635 "- [Cheat Sheets](#cheat-sheets)\n- [Other](#other)"
2636 );
2637 }
2638
2639 #[test]
2640 fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
2641 let toc = generated_toc(
2642 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\" data-name=\"pixel\"></a>\n",
2643 );
2644 assert_eq!(toc, "- [Foo](#foo)");
2645
2646 let result = check_toc(
2647 "# Title\n\n<!-- toc -->\n\n- [Foo](#tracking)\n\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\"></a>\n",
2648 );
2649 assert_eq!(result.len(), 1, "{result:?}");
2650 assert!(
2651 result[0].message.contains("Stale entry: 'Foo'"),
2652 "{}",
2653 result[0].message
2654 );
2655 assert!(
2656 result[0].message.contains("Missing entry: 'Foo'"),
2657 "{}",
2658 result[0].message
2659 );
2660 }
2661
2662 #[test]
2663 fn test_an_empty_id_is_not_an_anchor() {
2664 let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"\"></a>\n");
2665 assert_eq!(toc, "- [Foo](#foo)");
2666 }
2667
2668 #[test]
2669 fn test_an_explicit_anchor_still_consumes_the_generated_slug() {
2670 let toc =
2673 generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Same<a id=\"stable\"></a>\n\n## Same\n");
2674 assert_eq!(toc, "- [Same](#stable)\n- [Same](#same-1)");
2675 }
2676
2677 #[test]
2678 fn test_anchor_markup_inside_a_code_span_is_heading_text() {
2679 let heading = "Showing `<a id=\"literal\"></a>` syntax";
2680 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2681 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2682 assert_ne!(slug, "literal");
2683 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2684 }
2685
2686 #[test]
2687 fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
2688 let heading = "Foo <!-- <a id=\"hidden\"></a> -->";
2689 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2690 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2691 assert_ne!(slug, "hidden");
2692 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2693 }
2694
2695 #[test]
2696 fn test_an_explicit_anchor_is_written_as_a_valid_link_destination() {
2697 let headings = "## Alpha<a id=\"foo)bar\"></a>\n\n## Beta<a id=\"has space\"></a>\n";
2701 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{headings}"));
2702 assert_eq!(toc, "- [Alpha](#foo%29bar)\n- [Beta](#has%20space)");
2703
2704 let result = check_toc(&format!(
2705 "# Title\n\n<!-- toc -->\n\n{toc}\n\n<!-- tocstop -->\n\n{headings}"
2706 ));
2707 assert!(result.is_empty(), "{result:?}");
2708 }
2709
2710 #[test]
2711 fn test_regeneration_does_not_encode_an_encoded_entry_twice() {
2712 let content = "# Title\n\n<!-- toc -->\n\n- [Alpha](#foo%29bar)\n- [Gone](#gone)\n\n<!-- tocstop -->\n\n## Alpha<a id=\"foo)bar\"></a>\n";
2715 assert_eq!(generated_toc(content), "- [Alpha](#foo%29bar)");
2716 }
2717
2718 #[test]
2719 fn test_a_backslash_escaped_anchor_element_defines_no_target() {
2720 let heading = "Show \\<a id=\"example\"></a> syntax";
2722 let result = check_toc(&format!(
2723 "# Title\n\n<!-- toc -->\n\n- [{heading}](#example)\n\n<!-- tocstop -->\n\n## {heading}\n"
2724 ));
2725 assert_eq!(result.len(), 1, "{result:?}");
2726 assert!(result[0].message.contains("Stale entry"), "{}", result[0].message);
2727
2728 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2729 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2730 assert_ne!(slug, "example");
2731 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2732 }
2733
2734 #[test]
2735 fn test_a_backslash_escaped_marker_is_text() {
2736 let content = "# Title\n\nWrite these lines:\n\n\\<!-- toc -->\n\nProse that must survive.\n\n\\<!-- tocstop -->\n\n## Alpha\n";
2739 let result = check_toc(content);
2740 assert!(result.is_empty(), "{result:?}");
2741 assert_eq!(create_enabled_rule().fix(&create_ctx(content)).unwrap(), content);
2742
2743 let toc = generated_toc("# Title\n\n\\\\<!-- toc -->\n<!-- tocstop -->\n\n## Alpha\n");
2746 assert_eq!(toc, "- [Alpha](#alpha)");
2747 }
2748
2749 #[test]
2750 fn test_the_whitespace_beside_an_anchor_element_stays_in_the_heading_text() {
2751 let content =
2754 "# Title\n\n<!-- toc -->\n- [Foo Bar](#foo-bar)\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n";
2755 let warnings = check_toc(content);
2756 assert!(warnings.is_empty(), "{warnings:?}");
2757
2758 let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n");
2759 assert_eq!(toc, "- [Foo Bar](#alias)");
2760 }
2761
2762 #[test]
2763 fn test_the_whitespace_an_anchor_element_leaves_at_either_end_is_part_of_the_slug() {
2764 let document = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha-)\n- [Beta](#-beta)\n<!-- tocstop -->\n\n\
2769 ## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n";
2770 let warnings = check_toc(document);
2771 assert!(warnings.is_empty(), "{warnings:?}");
2772
2773 let trimmed = document.replace("#alpha-", "#alpha").replace("#-beta", "#beta");
2774 assert_eq!(
2775 check_toc(&trimmed).len(),
2776 1,
2777 "a TOC written against trimmed slugs is stale"
2778 );
2779
2780 let toc = generated_toc(
2781 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n",
2782 );
2783 assert_eq!(toc, "- [Alpha](#x)\n- [Beta](#y)");
2784 }
2785
2786 #[test]
2787 fn test_a_heading_whose_text_slugs_to_nothing_gets_no_toc_entry() {
2788 let body = "## \n\n## \n\n## Plain\n";
2794 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{body}"));
2795 assert_eq!(toc, "- [Plain](#plain)");
2796
2797 let warnings = check_toc(&format!(
2798 "# Title\n\n<!-- toc -->\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2799 ));
2800 assert!(warnings.is_empty(), "{warnings:?}");
2801
2802 let warnings = check_toc(&format!(
2803 "# Title\n\n<!-- toc -->\n- [img](#img)\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2804 ));
2805 assert_eq!(
2806 warnings.len(),
2807 1,
2808 "an entry for the image's anchor is stale: {warnings:?}"
2809 );
2810 }
2811
2812 #[test]
2813 fn test_an_anchor_inside_another_tags_attribute_value_is_heading_text() {
2814 let toc = generated_toc(
2817 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <span title='<a id=\"fake\"></a>'>Foo</span>\n",
2818 );
2819 assert_eq!(toc, "- [<span title='<a id=\"fake\"></a>'>Foo</span>](#foo)");
2820 }
2821
2822 #[test]
2823 fn test_a_heading_with_a_custom_id_keeps_its_html_anchor_as_a_target() {
2824 let heading = "<a name=\"old\"></a>My Section {#new}";
2827 let result = check_toc(&format!(
2828 "# Title\n\n<!-- toc -->\n\n- [My Section](#old)\n\n<!-- tocstop -->\n\n## {heading}\n"
2829 ));
2830 assert!(result.is_empty(), "{result:?}");
2831
2832 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2833 assert_eq!(toc, "- [My Section](#new)");
2834
2835 let result = check_toc(&format!(
2836 "# Title\n\n<!-- toc -->\n\n- [My Section](#my-section)\n\n<!-- tocstop -->\n\n## {heading}\n"
2837 ));
2838 assert_eq!(result.len(), 1, "the generated slug is replaced, got {result:?}");
2839 }
2840
2841 #[test]
2842 fn test_toc_entry_matches_a_multi_line_setext_heading() {
2843 let content = "# Title\n\n<!-- toc -->\n\n- [First line second line](#first-line-second-line)\n\n<!-- tocstop -->\n\nFirst line\nsecond line\n---\n\nContent.\n";
2846 assert!(check_toc(content).is_empty(), "{:?}", check_toc(content));
2847 assert_eq!(
2848 generated_toc(content),
2849 "- [First line second line](#first-line-second-line)"
2850 );
2851 }
2852
2853 #[test]
2854 fn test_missing_entry_names_the_first_line_of_a_multi_line_setext_heading() {
2855 let content = "# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\nFirst line\nsecond line\n---\n\nContent.\n";
2858 let result = check_toc(content);
2859 assert_eq!(result.len(), 1, "the TOC misses the heading, got {result:?}");
2860 assert!(
2861 result[0]
2862 .message
2863 .contains("Missing entry: 'First line second line' (line 7)"),
2864 "the heading starts on line 7: {}",
2865 result[0].message
2866 );
2867 }
2868 mod quoted_headings {
2869 use super::*;
2870
2871 fn warnings(content: &str) -> usize {
2872 let rule = create_enabled_rule();
2873 rule.check(&create_ctx(content)).unwrap().len()
2874 }
2875
2876 #[test]
2877 fn quoted_heading_belongs_in_the_toc() {
2878 let toc = |entries: &str| format!("# T\n\n<!-- toc -->\n\n{entries}\n<!-- tocstop -->\n\n## A\n\n> ## Q\n");
2879 assert_eq!(warnings(&toc("- [A](#a)\n- [Q](#q)\n")), 0);
2880 assert!(
2881 warnings(&toc("- [A](#a)\n")) > 0,
2882 "a quoted heading missing from the TOC is reported"
2883 );
2884 }
2885
2886 #[test]
2887 fn quoted_heading_in_every_container_shape_is_listed() {
2888 for body in ["> ## Q\n", "> > ## Q\n", "- x\n\n > ## Q\n", "> - x\n>\n> ## Q\n"] {
2889 let content = format!("# T\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n{body}");
2890 let rule = create_enabled_rule();
2891 let fixed = rule.fix(&create_ctx(&content)).unwrap();
2892 assert!(fixed.contains("[Q](#q)"), "{body:?}: {fixed:?}");
2893 }
2894 }
2895
2896 #[test]
2897 fn a_toc_inside_a_blockquote_is_reported_without_a_fix() {
2898 let content = "# T\n\n> <!-- toc -->\n>\n> <!-- tocstop -->\n\n## A\n";
2901 let rule = create_enabled_rule();
2902 let warnings = rule.check(&create_ctx(content)).unwrap();
2903 assert_eq!(warnings.len(), 1);
2904 assert!(warnings[0].fix.is_none());
2905 assert_eq!(rule.fix(&create_ctx(content)).unwrap(), content);
2906 }
2907
2908 #[test]
2909 fn a_correct_toc_inside_a_blockquote_is_valid() {
2910 let content =
2911 "# T\n\n> <!-- toc -->\n>\n> - [A](#a)\n> - [Q](#q)\n>\n> <!-- tocstop -->\n\n## A\n\n> ## Q\n";
2912 assert_eq!(warnings(content), 0);
2913 let stale = content.replace("> - [Q](#q)\n", "");
2914 assert_eq!(warnings(&stale), 1);
2915 }
2916
2917 #[test]
2918 fn a_quoted_toc_keeps_the_indentation_of_nested_entries() {
2919 for quote in [">", ">>"] {
2920 let mut toc = String::new();
2921 for line in ["<!-- toc -->", "", "- [A](#a)", " - [B](#b)", "", "<!-- tocstop -->"] {
2922 toc.push_str(quote);
2923 if !line.is_empty() {
2924 toc.push(' ');
2925 toc.push_str(line);
2926 }
2927 toc.push('\n');
2928 }
2929 let content = format!("# T\n\n{toc}\n## A\n\nx\n\n### B\n");
2930 assert_eq!(warnings(&content), 0, "{content:?}");
2931 let flat = content.replace(" - [B]", "- [B]");
2932 assert_eq!(warnings(&flat), 1, "{flat:?}");
2933 }
2934 }
2935
2936 #[test]
2937 fn a_quote_next_to_an_unquoted_toc_does_not_withhold_its_fix() {
2938 let rule = create_enabled_rule();
2939 for content in [
2940 "# T\n\n<!-- toc -->\n<!-- tocstop -->\n> Intro\n\n## A\n",
2941 "> Intro\n<!-- toc -->\n<!-- tocstop -->\n\n## A\n",
2942 "> Intro\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n> Outro\n\n## A\n",
2943 ] {
2944 let warnings = rule.check(&create_ctx(content)).unwrap();
2945 assert_eq!(warnings.len(), 1, "{content:?}");
2946 assert!(warnings[0].fix.is_some(), "{content:?}");
2947 }
2948 }
2949
2950 #[test]
2951 fn duplicates_are_numbered_across_quoted_and_unquoted_headings() {
2952 let content = "# T\n\n<!-- toc -->\n\n- [Q](#q)\n- [Q](#q-1)\n\n<!-- tocstop -->\n\n> ## Q\n\n## Q\n";
2953 assert_eq!(warnings(content), 0);
2954 }
2955
2956 #[test]
2957 fn headings_outside_the_level_range_still_take_a_number() {
2958 let content = "# Repeat\n\n<!-- toc -->\n\n- [Repeat](#repeat-1)\n\n<!-- tocstop -->\n\n## Repeat\n";
2961 assert_eq!(warnings(content), 0);
2962 let quoted = "<!-- toc -->\n\n- [Repeat](#repeat-1)\n\n<!-- tocstop -->\n\n> # Repeat\n\n## Repeat\n";
2963 assert_eq!(warnings(quoted), 0);
2964 }
2965
2966 #[test]
2967 fn quoted_heading_in_a_quoted_code_block_is_not_listed() {
2968 let content = "# T\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n> ```\n> ## Q\n> ```\n";
2969 assert_eq!(warnings(content), 0);
2970 }
2971 }
2972}