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 extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
387 let mut entries = Vec::new();
388
389 for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
390 let line_info = &ctx.lines[idx];
391 let content = line_info.content(ctx.content);
392
393 if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
394 let indent_spaces = caps.get(1).map_or(0, |m| m.as_str().len());
395 let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
396 let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
397
398 entries.push(TocEntry {
399 text,
400 anchor,
401 indent_spaces,
402 });
403 }
404 }
405
406 entries
407 }
408
409 fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
411 let mut entries = Vec::new();
412 let mut fragment_counts: HashMap<String, usize> = HashMap::new();
413
414 for (idx, line_info) in ctx.lines.iter().enumerate() {
415 let line_num = idx + 1;
416
417 if line_num <= toc_region.end_line {
419 continue;
421 }
422
423 if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
425 continue;
426 }
427
428 if let Some(heading) = &line_info.heading {
429 if heading.level < self.min_level || heading.level > self.max_level {
431 continue;
432 }
433
434 let (anchor, aliases) = match &heading.custom_id {
440 Some(custom_id) => (
441 Self::deduplicate_fragment(&mut fragment_counts, custom_id),
442 extract_html_anchor_ids(&heading.raw_text),
443 ),
444 None => {
445 let slug = AnchorStyle::GitHub.generate_fragment(&heading.slug_text);
446 let generated = Self::deduplicate_fragment(&mut fragment_counts, &slug);
447 let mut explicit = extract_html_anchor_ids(&heading.raw_text);
448 match explicit.first().cloned() {
449 Some(preferred) => {
450 explicit.remove(0);
451 explicit.push(generated);
452 (preferred, explicit)
453 }
454 None if slug.is_empty() => continue,
459 None => (generated, Vec::new()),
460 }
461 }
462 };
463
464 entries.push(ExpectedTocEntry {
465 heading_line: line_num,
466 level: heading.level,
467 text: heading.text.clone(),
468 anchor,
469 aliases,
470 });
471 }
472 }
473
474 entries
475 }
476
477 fn deduplicate_fragment(fragment_counts: &mut HashMap<String, usize>, base: &str) -> String {
480 match fragment_counts.get_mut(base) {
481 Some(count) => {
482 let suffix = *count;
483 *count += 1;
484 format!("{base}-{suffix}")
485 }
486 None => {
487 fragment_counts.insert(base.to_string(), 1);
488 base.to_string()
489 }
490 }
491 }
492
493 fn match_entries(actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<Option<usize>> {
496 let mut claimed = vec![false; expected.len()];
497 actual
498 .iter()
499 .map(|entry| {
500 let anchor = decode_fragment(&entry.anchor);
501 let found = expected
502 .iter()
503 .enumerate()
504 .position(|(idx, exp)| !claimed[idx] && exp.is_reached_by(&anchor));
505 if let Some(idx) = found {
506 claimed[idx] = true;
507 }
508 found
509 })
510 .collect()
511 }
512
513 fn validate_toc(
518 &self,
519 actual: &[TocEntry],
520 expected: &[ExpectedTocEntry],
521 matching: &[Option<usize>],
522 ) -> Vec<TocMismatch> {
523 let mut mismatches = Vec::new();
524 let pairs = || {
525 actual
526 .iter()
527 .zip(matching)
528 .enumerate()
529 .filter_map(|(actual_idx, (entry, matched))| matched.map(|exp_idx| (actual_idx, entry, exp_idx)))
530 };
531
532 for (entry, matched) in actual.iter().zip(matching) {
534 if matched.is_none() {
535 mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
536 }
537 }
538
539 for (exp_idx, exp) in expected.iter().enumerate() {
541 if !matching.contains(&Some(exp_idx)) {
542 mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
543 }
544 }
545
546 let mut text_mismatched = vec![false; actual.len()];
551 for (actual_idx, entry, exp_idx) in pairs() {
552 let exp = &expected[exp_idx];
553 let actual_normalized = strip_links_and_images(entry.text.trim());
554 let expected_normalized = strip_links_and_images(exp.text.trim());
555 if actual_normalized != expected_normalized {
556 text_mismatched[actual_idx] = true;
557 mismatches.push(TocMismatch::TextMismatch {
558 entry: entry.clone(),
559 expected: exp.clone(),
560 });
561 }
562 }
563
564 if !expected.is_empty() {
567 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
568
569 for (actual_idx, entry, exp_idx) in pairs() {
570 let level_diff = expected[exp_idx].level.saturating_sub(base_level) as usize;
571 let expected_indent = level_diff * self.indent;
572
573 if entry.indent_spaces != expected_indent && !text_mismatched[actual_idx] {
575 mismatches.push(TocMismatch::IndentationMismatch {
576 entry: entry.clone(),
577 actual_indent: entry.indent_spaces,
578 expected_indent,
579 });
580 }
581 }
582 }
583
584 if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
588 let mut expected_idx = 0;
589 for (actual_idx, entry, exp_idx) in pairs() {
590 if exp_idx >= expected_idx {
591 expected_idx = exp_idx + 1;
592 } else if !text_mismatched[actual_idx] {
593 mismatches.push(TocMismatch::OrderMismatch {
594 entry: entry.clone(),
595 expected_position: exp_idx + 1,
596 });
597 }
598 }
599 }
600
601 mismatches
602 }
603
604 fn keep_reached_anchors(
608 actual: &[TocEntry],
609 expected: &[ExpectedTocEntry],
610 matching: &[Option<usize>],
611 ) -> Vec<ExpectedTocEntry> {
612 let mut regenerated = expected.to_vec();
613 for (entry, exp_idx) in actual
614 .iter()
615 .zip(matching)
616 .filter_map(|(entry, m)| m.map(|idx| (entry, idx)))
617 {
618 regenerated[exp_idx].anchor = decode_fragment(&entry.anchor).into_owned();
619 }
620 regenerated
621 }
622
623 fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
625 if expected.is_empty() {
626 return String::new();
627 }
628
629 let mut result = String::new();
630 let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
631 let indent_str = " ".repeat(self.indent);
632
633 for entry in expected {
634 let level_diff = entry.level.saturating_sub(base_level) as usize;
635 let indent = indent_str.repeat(level_diff);
636
637 let display_text = strip_links_and_images(&entry.text);
641 let fragment = encode_fragment(&entry.anchor);
642 result.push_str(&format!("{indent}- [{display_text}](#{fragment})\n"));
643 }
644
645 result
646 }
647}
648
649impl Rule for MD073TocValidation {
650 fn name(&self) -> &'static str {
651 "MD073"
652 }
653
654 fn description(&self) -> &'static str {
655 "Table of Contents should match document headings"
656 }
657
658 fn should_skip(&self, ctx: &LintContext) -> bool {
659 let lower = ctx.content.to_ascii_lowercase();
664 !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
665 }
666
667 fn check(&self, ctx: &LintContext) -> LintResult {
668 let mut warnings = Vec::new();
669
670 let Some(region) = self.detect_toc_region(ctx) else {
672 return Ok(warnings);
674 };
675
676 let actual_entries = self.extract_toc_entries(ctx, ®ion);
678
679 let expected_entries = self.build_expected_toc(ctx, ®ion);
681
682 if expected_entries.is_empty() && actual_entries.is_empty() {
684 return Ok(warnings);
685 }
686
687 let matching = Self::match_entries(&actual_entries, &expected_entries);
689 let mismatches = self.validate_toc(&actual_entries, &expected_entries, &matching);
690
691 if !mismatches.is_empty() {
692 let mut details = Vec::new();
694
695 for mismatch in &mismatches {
696 match mismatch {
697 TocMismatch::StaleEntry { entry } => {
698 details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
699 }
700 TocMismatch::MissingEntry { expected } => {
701 details.push(format!(
702 "Missing entry: '{}' (line {})",
703 expected.text, expected.heading_line
704 ));
705 }
706 TocMismatch::TextMismatch { entry, expected } => {
707 details.push(format!(
708 "Text mismatch: TOC has '{}', heading is '{}'",
709 entry.text, expected.text
710 ));
711 }
712 TocMismatch::OrderMismatch {
713 entry,
714 expected_position,
715 } => {
716 details.push(format!(
717 "Order mismatch: '{}' should be at position {}",
718 entry.text, expected_position
719 ));
720 }
721 TocMismatch::IndentationMismatch {
722 entry,
723 actual_indent,
724 expected_indent,
725 ..
726 } => {
727 details.push(format!(
728 "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
729 entry.text, actual_indent, expected_indent
730 ));
731 }
732 }
733 }
734
735 let message = format!(
736 "Table of Contents does not match document headings: {}",
737 details.join("; ")
738 );
739
740 let regenerated = Self::keep_reached_anchors(&actual_entries, &expected_entries, &matching);
742 let new_toc = self.generate_toc(®enerated);
743 let fix_range = region.content_start..region.content_end;
744
745 warnings.push(LintWarning {
746 rule_name: Some(self.name().to_string()),
747 message,
748 line: region.start_line,
749 column: 1,
750 end_line: region.end_line,
751 end_column: 1,
752 severity: Severity::Warning,
753 fix: Some(Fix::new(fix_range, new_toc)),
754 });
755 }
756
757 Ok(warnings)
758 }
759
760 fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
761 if self.should_skip(ctx) {
762 return Ok(ctx.content.to_string());
763 }
764 let warnings = self.check(ctx)?;
765 if warnings.is_empty() {
766 return Ok(ctx.content.to_string());
767 }
768 let warnings =
769 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
770 crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
771 }
772
773 fn category(&self) -> RuleCategory {
774 RuleCategory::Other
775 }
776
777 fn as_any(&self) -> &dyn std::any::Any {
778 self
779 }
780
781 fn default_config_section(&self) -> Option<(String, toml::Value)> {
782 let value: toml::Value = toml::from_str(
783 r#"
784# Whether this rule is enabled (opt-in, disabled by default)
785enabled = false
786# Minimum heading level to include
787min-level = 2
788# Maximum heading level to include
789max-level = 4
790# Whether TOC order must match document order
791enforce-order = true
792# Indentation per nesting level (defaults to MD007's indent value)
793indent = 2
794"#,
795 )
796 .ok()?;
797 Some(("MD073".to_string(), value))
798 }
799
800 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
801 where
802 Self: Sized,
803 {
804 let mut rule = MD073TocValidation::default();
805 let mut indent_from_md073 = false;
806
807 if let Some(rule_config) = config.rules.get("MD073") {
808 if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
810 rule.enabled = enabled;
811 }
812
813 if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
815 rule.min_level = (min_level.clamp(1, 6)) as u8;
816 }
817
818 if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
820 rule.max_level = (max_level.clamp(1, 6)) as u8;
821 }
822
823 if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
825 rule.enforce_order = enforce_order;
826 }
827
828 if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
830 rule.indent = (indent.clamp(1, 8)) as usize;
831 indent_from_md073 = true;
832 }
833 }
834
835 if !indent_from_md073
837 && let Some(md007_config) = config.rules.get("MD007")
838 && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
839 {
840 rule.indent = (indent.clamp(1, 8)) as usize;
841 }
842
843 Box::new(rule)
844 }
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use crate::config::MarkdownFlavor;
851 use regex::Regex;
852 use std::sync::LazyLock;
853
854 fn strip_code_spans(text: &str) -> String {
860 let chars: Vec<char> = text.chars().collect();
861 let len = chars.len();
862 let mut result = String::with_capacity(text.len());
863 let mut i = 0;
864
865 while i < len {
866 if chars[i] == '`' {
867 let open_start = i;
868 while i < len && chars[i] == '`' {
869 i += 1;
870 }
871 let backtick_count = i - open_start;
872
873 let content_start = i;
874 let mut found_close = false;
875 while i < len {
876 if chars[i] == '`' {
877 let close_start = i;
878 while i < len && chars[i] == '`' {
879 i += 1;
880 }
881 if i - close_start == backtick_count {
882 let content: String = chars[content_start..close_start].iter().collect();
883 let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
884 content[1..content.len() - 1].to_string()
885 } else {
886 content
887 };
888 result.push_str(&stripped);
889 found_close = true;
890 break;
891 }
892 } else {
893 i += 1;
894 }
895 }
896 if !found_close {
897 for _ in 0..backtick_count {
898 result.push('`');
899 }
900 let remaining: String = chars[content_start..].iter().collect();
901 result.push_str(&remaining);
902 break;
903 }
904 } else {
905 result.push(chars[i]);
906 i += 1;
907 }
908 }
909
910 result
911 }
912
913 static TEST_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
914 static TEST_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
915 static TEST_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
916 static TEST_ITALIC_UNDERSCORE: LazyLock<Regex> =
917 LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
918
919 fn strip_markdown_formatting(text: &str) -> String {
923 let mut result = strip_links_and_images(text);
924 result = strip_code_spans(&result);
925 result = TEST_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
926 result = TEST_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
927 result = TEST_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
928 result = TEST_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
929 result
930 }
931
932 fn create_ctx(content: &str) -> LintContext<'_> {
933 LintContext::new(content, MarkdownFlavor::Standard, None)
934 }
935
936 fn create_enabled_rule() -> MD073TocValidation {
938 MD073TocValidation {
939 enabled: true,
940 ..MD073TocValidation::default()
941 }
942 }
943
944 #[test]
947 fn test_detect_markers_basic() {
948 let rule = MD073TocValidation::new();
949 let content = r#"# Title
950
951<!-- toc -->
952
953- [Heading 1](#heading-1)
954
955<!-- tocstop -->
956
957## Heading 1
958
959Content here.
960"#;
961 let ctx = create_ctx(content);
962 let region = rule.detect_by_markers(&ctx);
963 assert!(region.is_some());
964 let region = region.unwrap();
965 assert_eq!(region.start_line, 4);
967 assert_eq!(region.end_line, 6);
968 }
969
970 #[test]
971 fn test_detect_markers_variations() {
972 let rule = MD073TocValidation::new();
973
974 let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
976 let ctx1 = create_ctx(content1);
977 assert!(rule.detect_by_markers(&ctx1).is_some());
978
979 let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
981 let ctx2 = create_ctx(content2);
982 assert!(rule.detect_by_markers(&ctx2).is_some());
983
984 let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
986 let ctx3 = create_ctx(content3);
987 assert!(rule.detect_by_markers(&ctx3).is_some());
988 }
989
990 #[test]
991 fn test_no_toc_region() {
992 let rule = MD073TocValidation::new();
993 let content = r#"# Title
994
995## Heading 1
996
997Content here.
998
999## Heading 2
1000
1001More content.
1002"#;
1003 let ctx = create_ctx(content);
1004 let region = rule.detect_toc_region(&ctx);
1005 assert!(region.is_none());
1006 }
1007
1008 #[test]
1011 fn test_toc_matches_headings() {
1012 let rule = create_enabled_rule();
1013 let content = r#"# Title
1014
1015<!-- toc -->
1016
1017- [Heading 1](#heading-1)
1018- [Heading 2](#heading-2)
1019
1020<!-- tocstop -->
1021
1022## Heading 1
1023
1024Content.
1025
1026## Heading 2
1027
1028More content.
1029"#;
1030 let ctx = create_ctx(content);
1031 let result = rule.check(&ctx).unwrap();
1032 assert!(result.is_empty(), "Expected no warnings for matching TOC");
1033 }
1034
1035 #[test]
1036 fn test_missing_entry() {
1037 let rule = create_enabled_rule();
1038 let content = r#"# Title
1039
1040<!-- toc -->
1041
1042- [Heading 1](#heading-1)
1043
1044<!-- tocstop -->
1045
1046## Heading 1
1047
1048Content.
1049
1050## Heading 2
1051
1052New heading not in TOC.
1053"#;
1054 let ctx = create_ctx(content);
1055 let result = rule.check(&ctx).unwrap();
1056 assert_eq!(result.len(), 1);
1057 assert!(result[0].message.contains("Missing entry"));
1058 assert!(result[0].message.contains("Heading 2"));
1059 }
1060
1061 #[test]
1062 fn test_stale_entry() {
1063 let rule = create_enabled_rule();
1064 let content = r#"# Title
1065
1066<!-- toc -->
1067
1068- [Heading 1](#heading-1)
1069- [Deleted Heading](#deleted-heading)
1070
1071<!-- tocstop -->
1072
1073## Heading 1
1074
1075Content.
1076"#;
1077 let ctx = create_ctx(content);
1078 let result = rule.check(&ctx).unwrap();
1079 assert_eq!(result.len(), 1);
1080 assert!(result[0].message.contains("Stale entry"));
1081 assert!(result[0].message.contains("Deleted Heading"));
1082 }
1083
1084 #[test]
1085 fn test_text_mismatch() {
1086 let rule = create_enabled_rule();
1087 let content = r#"# Title
1088
1089<!-- toc -->
1090
1091- [Old Name](#heading-1)
1092
1093<!-- tocstop -->
1094
1095## Heading 1
1096
1097Content.
1098"#;
1099 let ctx = create_ctx(content);
1100 let result = rule.check(&ctx).unwrap();
1101 assert_eq!(result.len(), 1);
1102 assert!(result[0].message.contains("Text mismatch"));
1103 }
1104
1105 #[test]
1108 fn test_min_level_excludes_h1() {
1109 let mut rule = MD073TocValidation::new();
1110 rule.min_level = 2;
1111
1112 let content = r#"<!-- toc -->
1113
1114<!-- tocstop -->
1115
1116# Should Be Excluded
1117
1118## Should Be Included
1119
1120Content.
1121"#;
1122 let ctx = create_ctx(content);
1123 let region = rule.detect_toc_region(&ctx).unwrap();
1124 let expected = rule.build_expected_toc(&ctx, ®ion);
1125
1126 assert_eq!(expected.len(), 1);
1127 assert_eq!(expected[0].text, "Should Be Included");
1128 }
1129
1130 #[test]
1131 fn test_max_level_excludes_h5_h6() {
1132 let mut rule = MD073TocValidation::new();
1133 rule.max_level = 4;
1134
1135 let content = r#"<!-- toc -->
1136
1137<!-- tocstop -->
1138
1139## Level 2
1140
1141### Level 3
1142
1143#### Level 4
1144
1145##### Level 5 Should Be Excluded
1146
1147###### Level 6 Should Be Excluded
1148"#;
1149 let ctx = create_ctx(content);
1150 let region = rule.detect_toc_region(&ctx).unwrap();
1151 let expected = rule.build_expected_toc(&ctx, ®ion);
1152
1153 assert_eq!(expected.len(), 3);
1154 assert!(expected.iter().all(|e| e.level <= 4));
1155 }
1156
1157 #[test]
1160 fn test_fix_adds_missing_entry() {
1161 let rule = MD073TocValidation::new();
1162 let content = r#"# Title
1163
1164<!-- toc -->
1165
1166- [Heading 1](#heading-1)
1167
1168<!-- tocstop -->
1169
1170## Heading 1
1171
1172Content.
1173
1174## Heading 2
1175
1176New heading.
1177"#;
1178 let ctx = create_ctx(content);
1179 let fixed = rule.fix(&ctx).unwrap();
1180 assert!(fixed.contains("- [Heading 2](#heading-2)"));
1181 }
1182
1183 #[test]
1184 fn test_fix_removes_stale_entry() {
1185 let rule = MD073TocValidation::new();
1186 let content = r#"# Title
1187
1188<!-- toc -->
1189
1190- [Heading 1](#heading-1)
1191- [Deleted](#deleted)
1192
1193<!-- tocstop -->
1194
1195## Heading 1
1196
1197Content.
1198"#;
1199 let ctx = create_ctx(content);
1200 let fixed = rule.fix(&ctx).unwrap();
1201 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1202 assert!(!fixed.contains("Deleted"));
1203 }
1204
1205 #[test]
1206 fn test_fix_idempotent() {
1207 let rule = MD073TocValidation::new();
1208 let content = r#"# Title
1209
1210<!-- toc -->
1211
1212- [Heading 1](#heading-1)
1213- [Heading 2](#heading-2)
1214
1215<!-- tocstop -->
1216
1217## Heading 1
1218
1219Content.
1220
1221## Heading 2
1222
1223More.
1224"#;
1225 let ctx = create_ctx(content);
1226 let fixed1 = rule.fix(&ctx).unwrap();
1227 let ctx2 = create_ctx(&fixed1);
1228 let fixed2 = rule.fix(&ctx2).unwrap();
1229
1230 assert_eq!(fixed1, fixed2);
1232 }
1233
1234 #[test]
1235 fn test_fix_preserves_markers() {
1236 let rule = MD073TocValidation::new();
1237 let content = r#"# Title
1238
1239<!-- toc -->
1240
1241Old TOC content.
1242
1243<!-- tocstop -->
1244
1245## New Heading
1246
1247Content.
1248"#;
1249 let ctx = create_ctx(content);
1250 let fixed = rule.fix(&ctx).unwrap();
1251
1252 assert!(fixed.contains("<!-- toc -->"));
1254 assert!(fixed.contains("<!-- tocstop -->"));
1255 assert!(fixed.contains("- [New Heading](#new-heading)"));
1257 }
1258
1259 #[test]
1260 fn test_fix_requires_markers() {
1261 let rule = create_enabled_rule();
1262
1263 let content_no_markers = r#"# Title
1265
1266## Heading 1
1267
1268Content.
1269"#;
1270 let ctx = create_ctx(content_no_markers);
1271 let fixed = rule.fix(&ctx).unwrap();
1272 assert_eq!(fixed, content_no_markers);
1273
1274 let content_markers = r#"# Title
1276
1277<!-- toc -->
1278
1279- [Old Entry](#old-entry)
1280
1281<!-- tocstop -->
1282
1283## Heading 1
1284
1285Content.
1286"#;
1287 let ctx = create_ctx(content_markers);
1288 let fixed = rule.fix(&ctx).unwrap();
1289 assert!(fixed.contains("- [Heading 1](#heading-1)"));
1290 assert!(!fixed.contains("Old Entry"));
1291 }
1292
1293 #[test]
1296 fn test_duplicate_heading_anchors() {
1297 let rule = MD073TocValidation::new();
1298 let content = r#"# Title
1299
1300<!-- toc -->
1301
1302<!-- tocstop -->
1303
1304## Duplicate
1305
1306Content.
1307
1308## Duplicate
1309
1310More content.
1311
1312## Duplicate
1313
1314Even more.
1315"#;
1316 let ctx = create_ctx(content);
1317 let region = rule.detect_toc_region(&ctx).unwrap();
1318 let expected = rule.build_expected_toc(&ctx, ®ion);
1319
1320 assert_eq!(expected.len(), 3);
1321 assert_eq!(expected[0].anchor, "duplicate");
1322 assert_eq!(expected[1].anchor, "duplicate-1");
1323 assert_eq!(expected[2].anchor, "duplicate-2");
1324 }
1325
1326 #[test]
1329 fn test_headings_in_code_blocks_ignored() {
1330 let rule = create_enabled_rule();
1331 let content = r#"# Title
1332
1333<!-- toc -->
1334
1335- [Real Heading](#real-heading)
1336
1337<!-- tocstop -->
1338
1339## Real Heading
1340
1341```markdown
1342## Fake Heading In Code
1343```
1344
1345Content.
1346"#;
1347 let ctx = create_ctx(content);
1348 let result = rule.check(&ctx).unwrap();
1349 assert!(result.is_empty(), "Should not report fake heading in code block");
1350 }
1351
1352 #[test]
1353 fn test_empty_toc_region() {
1354 let rule = create_enabled_rule();
1355 let content = r#"# Title
1356
1357<!-- toc -->
1358<!-- tocstop -->
1359
1360## Heading 1
1361
1362Content.
1363"#;
1364 let ctx = create_ctx(content);
1365 let result = rule.check(&ctx).unwrap();
1366 assert_eq!(result.len(), 1);
1367 assert!(result[0].message.contains("Missing entry"));
1368 }
1369
1370 #[test]
1371 fn test_nested_indentation() {
1372 let rule = create_enabled_rule();
1373
1374 let content = r#"<!-- toc -->
1375
1376<!-- tocstop -->
1377
1378## Level 2
1379
1380### Level 3
1381
1382#### Level 4
1383
1384## Another Level 2
1385"#;
1386 let ctx = create_ctx(content);
1387 let region = rule.detect_toc_region(&ctx).unwrap();
1388 let expected = rule.build_expected_toc(&ctx, ®ion);
1389 let toc = rule.generate_toc(&expected);
1390
1391 assert!(toc.contains("- [Level 2](#level-2)"));
1393 assert!(toc.contains(" - [Level 3](#level-3)"));
1394 assert!(toc.contains(" - [Level 4](#level-4)"));
1395 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1396 }
1397
1398 #[test]
1401 fn test_indentation_mismatch_detected() {
1402 let rule = create_enabled_rule();
1403 let content = r#"<!-- toc -->
1405- [Hello](#hello)
1406- [Another](#another)
1407- [Heading](#heading)
1408<!-- tocstop -->
1409
1410## Hello
1411
1412### Another
1413
1414## Heading
1415"#;
1416 let ctx = create_ctx(content);
1417 let result = rule.check(&ctx).unwrap();
1418 assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1420 assert!(
1421 result[0].message.contains("Indentation mismatch"),
1422 "Message should mention indentation: {}",
1423 result[0].message
1424 );
1425 assert!(
1426 result[0].message.contains("Another"),
1427 "Message should mention the entry: {}",
1428 result[0].message
1429 );
1430 }
1431
1432 #[test]
1433 fn test_indentation_mismatch_fixed() {
1434 let rule = create_enabled_rule();
1435 let content = r#"<!-- toc -->
1437- [Hello](#hello)
1438- [Another](#another)
1439- [Heading](#heading)
1440<!-- tocstop -->
1441
1442## Hello
1443
1444### Another
1445
1446## Heading
1447"#;
1448 let ctx = create_ctx(content);
1449 let fixed = rule.fix(&ctx).unwrap();
1450 assert!(fixed.contains("- [Hello](#hello)"));
1452 assert!(fixed.contains(" - [Another](#another)")); assert!(fixed.contains("- [Heading](#heading)"));
1454 }
1455
1456 #[test]
1457 fn test_no_indentation_mismatch_when_correct() {
1458 let rule = create_enabled_rule();
1459 let content = r#"<!-- toc -->
1461- [Hello](#hello)
1462 - [Another](#another)
1463- [Heading](#heading)
1464<!-- tocstop -->
1465
1466## Hello
1467
1468### Another
1469
1470## Heading
1471"#;
1472 let ctx = create_ctx(content);
1473 let result = rule.check(&ctx).unwrap();
1474 assert!(result.is_empty(), "Should not report issues: {result:?}");
1476 }
1477
1478 #[test]
1481 fn test_order_mismatch_detected() {
1482 let rule = create_enabled_rule();
1483 let content = r#"# Title
1484
1485<!-- toc -->
1486
1487- [Section B](#section-b)
1488- [Section A](#section-a)
1489
1490<!-- tocstop -->
1491
1492## Section A
1493
1494Content A.
1495
1496## Section B
1497
1498Content B.
1499"#;
1500 let ctx = create_ctx(content);
1501 let result = rule.check(&ctx).unwrap();
1502 assert!(!result.is_empty(), "Should detect order mismatch");
1505 }
1506
1507 #[test]
1508 fn test_order_mismatch_ignored_when_disabled() {
1509 let mut rule = create_enabled_rule();
1510 rule.enforce_order = false;
1511 let content = r#"# Title
1512
1513<!-- toc -->
1514
1515- [Section B](#section-b)
1516- [Section A](#section-a)
1517
1518<!-- tocstop -->
1519
1520## Section A
1521
1522Content A.
1523
1524## Section B
1525
1526Content B.
1527"#;
1528 let ctx = create_ctx(content);
1529 let result = rule.check(&ctx).unwrap();
1530 assert!(result.is_empty(), "Should not report order mismatch when disabled");
1532 }
1533
1534 #[test]
1537 fn test_unicode_headings() {
1538 let rule = create_enabled_rule();
1539 let content = r#"# Title
1540
1541<!-- toc -->
1542
1543- [日本語の見出し](#日本語の見出し)
1544- [Émojis 🎉](#émojis-)
1545
1546<!-- tocstop -->
1547
1548## 日本語の見出し
1549
1550Japanese content.
1551
1552## Émojis 🎉
1553
1554Content with emojis.
1555"#;
1556 let ctx = create_ctx(content);
1557 let result = rule.check(&ctx).unwrap();
1558 assert!(result.is_empty(), "Should handle unicode headings");
1560 }
1561
1562 #[test]
1563 fn test_special_characters_in_headings() {
1564 let rule = create_enabled_rule();
1565 let content = r#"# Title
1566
1567<!-- toc -->
1568
1569- [What's New?](#whats-new)
1570- [C++ Guide](#c-guide)
1571
1572<!-- tocstop -->
1573
1574## What's New?
1575
1576News content.
1577
1578## C++ Guide
1579
1580C++ content.
1581"#;
1582 let ctx = create_ctx(content);
1583 let result = rule.check(&ctx).unwrap();
1584 assert!(result.is_empty(), "Should handle special characters");
1585 }
1586
1587 #[test]
1588 fn test_code_spans_in_headings() {
1589 let rule = create_enabled_rule();
1590 let content = r#"# Title
1591
1592<!-- toc -->
1593
1594- [`check [PATHS...]`](#check-paths)
1595
1596<!-- tocstop -->
1597
1598## `check [PATHS...]`
1599
1600Command documentation.
1601"#;
1602 let ctx = create_ctx(content);
1603 let result = rule.check(&ctx).unwrap();
1604 assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1605 }
1606
1607 #[test]
1610 fn test_from_config_defaults() {
1611 let config = crate::config::Config::default();
1612 let rule = MD073TocValidation::from_config(&config);
1613 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1614
1615 assert_eq!(rule.min_level, 2);
1616 assert_eq!(rule.max_level, 4);
1617 assert!(rule.enforce_order);
1618 assert_eq!(rule.indent, 2);
1619 }
1620
1621 #[test]
1622 fn test_indent_from_md007_config() {
1623 use crate::config::{Config, RuleConfig};
1624 use std::collections::BTreeMap;
1625
1626 let mut config = Config::default();
1627
1628 let mut md007_values = BTreeMap::new();
1630 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1631 config.rules.insert(
1632 "MD007".to_string(),
1633 RuleConfig {
1634 severity: None,
1635 values: md007_values,
1636 },
1637 );
1638
1639 let rule = MD073TocValidation::from_config(&config);
1640 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1641
1642 assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1643 }
1644
1645 #[test]
1646 fn test_indent_md073_overrides_md007() {
1647 use crate::config::{Config, RuleConfig};
1648 use std::collections::BTreeMap;
1649
1650 let mut config = Config::default();
1651
1652 let mut md007_values = BTreeMap::new();
1654 md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1655 config.rules.insert(
1656 "MD007".to_string(),
1657 RuleConfig {
1658 severity: None,
1659 values: md007_values,
1660 },
1661 );
1662
1663 let mut md073_values = BTreeMap::new();
1665 md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1666 md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1667 config.rules.insert(
1668 "MD073".to_string(),
1669 RuleConfig {
1670 severity: None,
1671 values: md073_values,
1672 },
1673 );
1674
1675 let rule = MD073TocValidation::from_config(&config);
1676 let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1677
1678 assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1679 }
1680
1681 #[test]
1682 fn test_generate_toc_with_4_space_indent() {
1683 let mut rule = create_enabled_rule();
1684 rule.indent = 4;
1685
1686 let content = r#"<!-- toc -->
1687
1688<!-- tocstop -->
1689
1690## Level 2
1691
1692### Level 3
1693
1694#### Level 4
1695
1696## Another Level 2
1697"#;
1698 let ctx = create_ctx(content);
1699 let region = rule.detect_toc_region(&ctx).unwrap();
1700 let expected = rule.build_expected_toc(&ctx, ®ion);
1701 let toc = rule.generate_toc(&expected);
1702
1703 assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1708 assert!(
1709 toc.contains(" - [Level 3](#level-3)"),
1710 "Level 3 should have 4-space indent"
1711 );
1712 assert!(
1713 toc.contains(" - [Level 4](#level-4)"),
1714 "Level 4 should have 8-space indent"
1715 );
1716 assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1717 }
1718
1719 #[test]
1720 fn test_validate_toc_with_4_space_indent() {
1721 let mut rule = create_enabled_rule();
1722 rule.indent = 4;
1723
1724 let content = r#"<!-- toc -->
1726- [Hello](#hello)
1727 - [Another](#another)
1728- [Heading](#heading)
1729<!-- tocstop -->
1730
1731## Hello
1732
1733### Another
1734
1735## Heading
1736"#;
1737 let ctx = create_ctx(content);
1738 let result = rule.check(&ctx).unwrap();
1739 assert!(
1740 result.is_empty(),
1741 "Should accept 4-space indent when configured: {result:?}"
1742 );
1743 }
1744
1745 #[test]
1746 fn test_validate_toc_wrong_indent_with_4_space_config() {
1747 let mut rule = create_enabled_rule();
1748 rule.indent = 4;
1749
1750 let content = r#"<!-- toc -->
1752- [Hello](#hello)
1753 - [Another](#another)
1754- [Heading](#heading)
1755<!-- tocstop -->
1756
1757## Hello
1758
1759### Another
1760
1761## Heading
1762"#;
1763 let ctx = create_ctx(content);
1764 let result = rule.check(&ctx).unwrap();
1765 assert_eq!(result.len(), 1, "Should detect wrong indent");
1766 assert!(
1767 result[0].message.contains("Indentation mismatch"),
1768 "Should report indentation mismatch: {}",
1769 result[0].message
1770 );
1771 assert!(
1772 result[0].message.contains("expected 4 spaces"),
1773 "Should mention expected 4 spaces: {}",
1774 result[0].message
1775 );
1776 }
1777
1778 #[test]
1781 fn test_strip_markdown_formatting_link() {
1782 let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1783 assert_eq!(result, "Tool: terminal");
1784 }
1785
1786 #[test]
1787 fn test_strip_markdown_formatting_bold() {
1788 let result = strip_markdown_formatting("This is **bold** text");
1789 assert_eq!(result, "This is bold text");
1790
1791 let result = strip_markdown_formatting("This is __bold__ text");
1792 assert_eq!(result, "This is bold text");
1793 }
1794
1795 #[test]
1796 fn test_strip_markdown_formatting_italic() {
1797 let result = strip_markdown_formatting("This is *italic* text");
1798 assert_eq!(result, "This is italic text");
1799
1800 let result = strip_markdown_formatting("This is _italic_ text");
1801 assert_eq!(result, "This is italic text");
1802 }
1803
1804 #[test]
1805 fn test_strip_markdown_formatting_code_span() {
1806 let result = strip_markdown_formatting("Use the `format` function");
1807 assert_eq!(result, "Use the format function");
1808 }
1809
1810 #[test]
1811 fn test_strip_markdown_formatting_image() {
1812 let result = strip_markdown_formatting("See  for details");
1813 assert_eq!(result, "See logo for details");
1814 }
1815
1816 #[test]
1817 fn test_strip_markdown_formatting_reference_link() {
1818 let result = strip_markdown_formatting("See [documentation][docs] for details");
1819 assert_eq!(result, "See documentation for details");
1820 }
1821
1822 #[test]
1823 fn test_strip_markdown_formatting_combined() {
1824 let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1826 assert_eq!(result, "Tool: terminal");
1827 }
1828
1829 #[test]
1830 fn test_toc_with_link_in_heading_matches_stripped_text() {
1831 let rule = create_enabled_rule();
1832
1833 let content = r#"# Title
1835
1836<!-- toc -->
1837
1838- [Tool: terminal](#tool-terminal)
1839
1840<!-- tocstop -->
1841
1842## Tool: [terminal](https://example.com)
1843
1844Content here.
1845"#;
1846 let ctx = create_ctx(content);
1847 let result = rule.check(&ctx).unwrap();
1848 assert!(
1849 result.is_empty(),
1850 "Stripped heading text should match TOC entry: {result:?}"
1851 );
1852 }
1853
1854 #[test]
1855 fn test_toc_with_simplified_text_still_mismatches() {
1856 let rule = create_enabled_rule();
1857
1858 let content = r#"# Title
1860
1861<!-- toc -->
1862
1863- [terminal](#tool-terminal)
1864
1865<!-- tocstop -->
1866
1867## Tool: [terminal](https://example.com)
1868
1869Content here.
1870"#;
1871 let ctx = create_ctx(content);
1872 let result = rule.check(&ctx).unwrap();
1873 assert_eq!(result.len(), 1, "Should report text mismatch");
1874 assert!(result[0].message.contains("Text mismatch"));
1875 }
1876
1877 #[test]
1878 fn test_fix_generates_stripped_toc_entries() {
1879 let rule = MD073TocValidation::new();
1880 let content = r#"# Title
1881
1882<!-- toc -->
1883
1884<!-- tocstop -->
1885
1886## Tool: [busybox](https://www.busybox.net/)
1887
1888Content.
1889
1890## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1891
1892More content.
1893"#;
1894 let ctx = create_ctx(content);
1895 let fixed = rule.fix(&ctx).unwrap();
1896
1897 assert!(
1899 fixed.contains("- [Tool: busybox](#tool-busybox)"),
1900 "TOC entry should have stripped link text"
1901 );
1902 assert!(
1903 fixed.contains("- [Tool: mount](#tool-mount)"),
1904 "TOC entry should have stripped link text"
1905 );
1906 let toc_start = fixed.find("<!-- toc -->").unwrap();
1909 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1910 let toc_content = &fixed[toc_start..toc_end];
1911 assert!(
1912 !toc_content.contains("busybox.net"),
1913 "TOC should not contain URLs: {toc_content}"
1914 );
1915 assert!(
1916 !toc_content.contains("wikipedia.org"),
1917 "TOC should not contain URLs: {toc_content}"
1918 );
1919 }
1920
1921 #[test]
1922 fn test_fix_with_bold_in_heading() {
1923 let rule = MD073TocValidation::new();
1924 let content = r#"# Title
1925
1926<!-- toc -->
1927
1928<!-- tocstop -->
1929
1930## **Important** Section
1931
1932Content.
1933"#;
1934 let ctx = create_ctx(content);
1935 let fixed = rule.fix(&ctx).unwrap();
1936
1937 assert!(fixed.contains("- [**Important** Section](#important-section)"));
1939 }
1940
1941 #[test]
1942 fn test_fix_with_code_in_heading() {
1943 let rule = MD073TocValidation::new();
1944 let content = r#"# Title
1945
1946<!-- toc -->
1947
1948<!-- tocstop -->
1949
1950## Using `async` Functions
1951
1952Content.
1953"#;
1954 let ctx = create_ctx(content);
1955 let fixed = rule.fix(&ctx).unwrap();
1956
1957 assert!(fixed.contains("- [Using `async` Functions](#using-async-functions)"));
1959 }
1960
1961 #[test]
1964 fn test_custom_anchor_id_respected() {
1965 let rule = create_enabled_rule();
1966 let content = r#"# Title
1967
1968<!-- toc -->
1969
1970- [My Section](#my-custom-anchor)
1971
1972<!-- tocstop -->
1973
1974## My Section {#my-custom-anchor}
1975
1976Content here.
1977"#;
1978 let ctx = create_ctx(content);
1979 let result = rule.check(&ctx).unwrap();
1980 assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1981 }
1982
1983 #[test]
1984 fn test_custom_anchor_id_in_generated_toc() {
1985 let rule = create_enabled_rule();
1986 let content = r#"# Title
1987
1988<!-- toc -->
1989
1990<!-- tocstop -->
1991
1992## First Section {#custom-first}
1993
1994Content.
1995
1996## Second Section {#another-custom}
1997
1998More content.
1999"#;
2000 let ctx = create_ctx(content);
2001 let fixed = rule.fix(&ctx).unwrap();
2002 assert!(fixed.contains("- [First Section](#custom-first)"));
2003 assert!(fixed.contains("- [Second Section](#another-custom)"));
2004 }
2005
2006 #[test]
2007 fn test_mixed_custom_and_generated_anchors() {
2008 let rule = create_enabled_rule();
2009 let content = r#"# Title
2010
2011<!-- toc -->
2012
2013- [Custom Section](#my-id)
2014- [Normal Section](#normal-section)
2015
2016<!-- tocstop -->
2017
2018## Custom Section {#my-id}
2019
2020Content.
2021
2022## Normal Section
2023
2024More content.
2025"#;
2026 let ctx = create_ctx(content);
2027 let result = rule.check(&ctx).unwrap();
2028 assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
2029 }
2030
2031 #[test]
2034 fn test_github_anchor_style() {
2035 let rule = create_enabled_rule();
2036
2037 let content = r#"<!-- toc -->
2038
2039<!-- tocstop -->
2040
2041## Test_With_Underscores
2042
2043Content.
2044"#;
2045 let ctx = create_ctx(content);
2046 let region = rule.detect_toc_region(&ctx).unwrap();
2047 let expected = rule.build_expected_toc(&ctx, ®ion);
2048
2049 assert_eq!(expected[0].anchor, "test_with_underscores");
2051 }
2052
2053 #[test]
2056 fn test_stress_many_headings() {
2057 let rule = create_enabled_rule();
2058
2059 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2061
2062 for i in 1..=150 {
2063 content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
2064 }
2065
2066 let ctx = create_ctx(&content);
2067
2068 let result = rule.check(&ctx).unwrap();
2070
2071 assert_eq!(result.len(), 1, "Should report single warning for TOC");
2073 assert!(result[0].message.contains("Missing entry"));
2074
2075 let fixed = rule.fix(&ctx).unwrap();
2077 assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
2078 assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
2079 assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
2080 }
2081
2082 #[test]
2083 fn test_stress_deeply_nested() {
2084 let rule = create_enabled_rule();
2085 let content = r#"# Title
2086
2087<!-- toc -->
2088
2089<!-- tocstop -->
2090
2091## Level 2 A
2092
2093### Level 3 A
2094
2095#### Level 4 A
2096
2097## Level 2 B
2098
2099### Level 3 B
2100
2101#### Level 4 B
2102
2103## Level 2 C
2104
2105### Level 3 C
2106
2107#### Level 4 C
2108
2109## Level 2 D
2110
2111### Level 3 D
2112
2113#### Level 4 D
2114"#;
2115 let ctx = create_ctx(content);
2116 let fixed = rule.fix(&ctx).unwrap();
2117
2118 assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
2120 assert!(fixed.contains(" - [Level 3 A](#level-3-a)"));
2121 assert!(fixed.contains(" - [Level 4 A](#level-4-a)"));
2122 assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
2123 assert!(fixed.contains(" - [Level 3 D](#level-3-d)"));
2124 assert!(fixed.contains(" - [Level 4 D](#level-4-d)"));
2125 }
2126
2127 #[test]
2130 fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
2131 let rule = create_enabled_rule();
2132 let content = r#"# Document
2136
2137<!-- toc -->
2138
2139- [Example](#example)
2140- [Another](#another)
2141- [Example](#example)
2142
2143<!-- tocstop -->
2144
2145## Example
2146First.
2147
2148## Another
2149Middle.
2150
2151## Example
2152Second.
2153"#;
2154 let ctx = create_ctx(content);
2155 let result = rule.check(&ctx).unwrap();
2156
2157 assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
2160 assert!(
2161 result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
2162 "Should report missing or stale entries for duplicate anchors. Got: {}",
2163 result[0].message
2164 );
2165 }
2166
2167 #[test]
2170 fn test_strip_double_backtick_code_span() {
2171 let result = strip_markdown_formatting("Using ``code with ` backtick``");
2173 assert_eq!(
2174 result, "Using code with ` backtick",
2175 "Should strip double-backtick code spans"
2176 );
2177 }
2178
2179 #[test]
2180 fn test_strip_triple_backtick_code_span() {
2181 let result = strip_markdown_formatting("Using ```code with `` backticks```");
2183 assert_eq!(
2184 result, "Using code with `` backticks",
2185 "Should strip triple-backtick code spans"
2186 );
2187 }
2188
2189 #[test]
2190 fn test_toc_with_double_backtick_heading() {
2191 let rule = create_enabled_rule();
2192 let content = r#"# Title
2195
2196<!-- toc -->
2197
2198<!-- tocstop -->
2199
2200## Using ``code with ` backtick``
2201
2202Content here.
2203"#;
2204 let ctx = create_ctx(content);
2205 let fixed = rule.fix(&ctx).unwrap();
2208
2209 let toc_start = fixed.find("<!-- toc -->").unwrap();
2212 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2213 let toc_content = &fixed[toc_start..toc_end];
2214 assert!(
2215 toc_content.contains("``code with ` backtick``"),
2216 "Fix should preserve double-backtick code span in TOC display text. Got: {toc_content}"
2217 );
2218
2219 let ctx2 = create_ctx(&fixed);
2221 let result = rule.check(&ctx2).unwrap();
2222 assert!(
2223 result.is_empty(),
2224 "check() should not warn on fixed output. Warnings: {result:?}"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_stress_many_duplicates() {
2230 let rule = create_enabled_rule();
2231
2232 let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2234 for _ in 0..50 {
2235 content.push_str("## FAQ\n\nContent.\n\n");
2236 }
2237
2238 let ctx = create_ctx(&content);
2239 let region = rule.detect_toc_region(&ctx).unwrap();
2240 let expected = rule.build_expected_toc(&ctx, ®ion);
2241
2242 assert_eq!(expected.len(), 50);
2244 assert_eq!(expected[0].anchor, "faq");
2245 assert_eq!(expected[1].anchor, "faq-1");
2246 assert_eq!(expected[49].anchor, "faq-49");
2247 }
2248
2249 #[test]
2252 fn test_roundtrip_check_and_fix_alignment() {
2253 let rule = create_enabled_rule();
2254
2255 let inputs = [
2256 "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2258 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2260 "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2262 "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2264 ];
2265
2266 for input in &inputs {
2267 let ctx = create_ctx(input);
2268 let fixed = rule.fix(&ctx).unwrap();
2269
2270 let ctx2 = create_ctx(&fixed);
2272 let fixed_twice = rule.fix(&ctx2).unwrap();
2273 assert_eq!(
2274 fixed, fixed_twice,
2275 "fix() is not idempotent for input: {input:?}\nfirst: {fixed:?}\nsecond: {fixed_twice:?}"
2276 );
2277
2278 let warnings_after = rule.check(&ctx2).unwrap();
2280 assert!(
2281 warnings_after.is_empty(),
2282 "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2283 );
2284 }
2285 }
2286
2287 #[test]
2290 fn test_no_mismatch_preserves_content() {
2291 let rule = create_enabled_rule();
2292
2293 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";
2294 let ctx = create_ctx(content);
2295
2296 let warnings = rule.check(&ctx).unwrap();
2297 assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2298
2299 let fixed = rule.fix(&ctx).unwrap();
2300 assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2301 }
2302
2303 #[test]
2305 fn test_inline_disable_preserves_toc() {
2306 let rule = create_enabled_rule();
2307
2308 let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2310 let ctx = create_ctx(content);
2311
2312 let fixed = rule.fix(&ctx).unwrap();
2313 assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2314 }
2315
2316 #[test]
2322 fn test_fix_code_ticks_preserved_in_toc_display_text() {
2323 let rule = MD073TocValidation::new();
2324 let content = r#"# Title
2325
2326<!-- toc -->
2327
2328<!-- tocstop -->
2329
2330### `my header`
2331
2332Content.
2333"#;
2334 let ctx = create_ctx(content);
2335 let fixed = rule.fix(&ctx).unwrap();
2336
2337 assert!(
2338 fixed.contains("- [`my header`](#my-header)"),
2339 "Code ticks must be preserved in TOC display text. Got: {fixed}"
2340 );
2341 }
2342
2343 #[test]
2345 fn test_validate_toc_with_code_ticks_is_valid() {
2346 let rule = create_enabled_rule();
2347 let content = r#"# Title
2348
2349<!-- toc -->
2350
2351- [`my header`](#my-header)
2352
2353<!-- tocstop -->
2354
2355## `my header`
2356
2357Content.
2358"#;
2359 let ctx = create_ctx(content);
2360 let result = rule.check(&ctx).unwrap();
2361 assert!(
2362 result.is_empty(),
2363 "A TOC entry with preserved code ticks should be accepted as valid: {result:?}"
2364 );
2365 }
2366
2367 #[test]
2371 fn test_fix_emphasis_preserved_in_toc_display_text() {
2372 let rule = MD073TocValidation::new();
2373 let content = r#"# Title
2374
2375<!-- toc -->
2376
2377<!-- tocstop -->
2378
2379## **bold** and *italic*
2380
2381Content.
2382"#;
2383 let ctx = create_ctx(content);
2384 let fixed = rule.fix(&ctx).unwrap();
2385
2386 assert!(
2387 fixed.contains("- [**bold** and *italic*](#bold-and-italic)"),
2388 "Emphasis markers must be preserved in TOC display text. Got: {fixed}"
2389 );
2390 }
2391
2392 #[test]
2395 fn test_fix_link_in_heading_is_stripped() {
2396 let rule = MD073TocValidation::new();
2397 let content = r#"# Title
2398
2399<!-- toc -->
2400
2401<!-- tocstop -->
2402
2403## See [docs](http://example.com) for details
2404
2405Content.
2406"#;
2407 let ctx = create_ctx(content);
2408 let fixed = rule.fix(&ctx).unwrap();
2409
2410 assert!(
2411 fixed.contains("- [See docs for details](#see-docs-for-details)"),
2412 "Link must be stripped from TOC display text. Got: {fixed}"
2413 );
2414 let toc_start = fixed.find("<!-- toc -->").unwrap();
2416 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2417 let toc_content = &fixed[toc_start..toc_end];
2418 assert!(
2419 !toc_content.contains("http://example.com"),
2420 "TOC should not contain link URL: {toc_content}"
2421 );
2422 }
2423
2424 #[test]
2426 fn test_fix_image_in_heading_is_stripped() {
2427 let rule = MD073TocValidation::new();
2428 let content = r#"# Title
2429
2430<!-- toc -->
2431
2432<!-- tocstop -->
2433
2434## Section  Title
2435
2436Content.
2437"#;
2438 let ctx = create_ctx(content);
2439 let fixed = rule.fix(&ctx).unwrap();
2440
2441 assert!(
2442 fixed.contains("- [Section icon Title](#section-icon-title)"),
2443 "Image must be stripped from TOC display text. Got: {fixed}"
2444 );
2445 }
2446
2447 #[test]
2450 fn test_fix_idempotent_with_inline_formatting() {
2451 let rule = MD073TocValidation::new();
2452 let content = r#"# Title
2453
2454<!-- toc -->
2455
2456<!-- tocstop -->
2457
2458## `code` heading
2459
2460### **bold** heading
2461
2462## See [link](http://x.com)
2463
2464"#;
2465 let ctx = create_ctx(content);
2466 let fixed1 = rule.fix(&ctx).unwrap();
2467 let ctx2 = create_ctx(&fixed1);
2468 let fixed2 = rule.fix(&ctx2).unwrap();
2469
2470 assert_eq!(fixed1, fixed2, "fix() must be idempotent for inline-formatted headings");
2471
2472 let warnings = rule.check(&ctx2).unwrap();
2474 assert!(
2475 warnings.is_empty(),
2476 "check() must not warn after fix() for inline-formatted headings: {warnings:?}"
2477 );
2478 }
2479
2480 #[test]
2483 fn test_link_inside_code_span_preserved_in_toc() {
2484 let rule = MD073TocValidation::new();
2485 let content = r#"# Title
2486
2487<!-- toc -->
2488
2489<!-- tocstop -->
2490
2491## Use `[foo](bar)` syntax
2492
2493Content.
2494"#;
2495 let ctx = create_ctx(content);
2496 let fixed = rule.fix(&ctx).unwrap();
2497
2498 let toc_start = fixed.find("<!-- toc -->").unwrap();
2504 let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2505 let toc_content = &fixed[toc_start..toc_end];
2506 assert!(
2507 toc_content.contains("Use `[foo](bar)` syntax"),
2508 "Link-like text inside code span must be preserved in TOC display text. Got: {toc_content}"
2509 );
2510 assert!(
2512 !toc_content.contains("http://"),
2513 "Real links (outside code spans) should be stripped: {toc_content}"
2514 );
2515 }
2516
2517 fn generated_toc(content: &str) -> String {
2521 let rule = create_enabled_rule();
2522 let ctx = create_ctx(content);
2523 let fixed = rule.fix(&ctx).unwrap();
2524 let start = fixed.find("<!-- toc -->").unwrap() + "<!-- toc -->".len();
2525 let end = fixed.find("<!-- tocstop -->").unwrap();
2526 fixed[start..end].trim().to_string()
2527 }
2528
2529 fn check_toc(content: &str) -> Vec<LintWarning> {
2530 let rule = create_enabled_rule();
2531 let ctx = create_ctx(content);
2532 rule.check(&ctx).unwrap()
2533 }
2534
2535 #[test]
2536 fn test_toc_may_target_the_generated_slug_of_a_heading_with_an_html_anchor() {
2537 for anchor in ["cheat-sheets", "cheatsheets"] {
2540 let content = format!(
2541 "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\nContent.\n"
2542 );
2543 let result = check_toc(&content);
2544 assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2545 }
2546 }
2547
2548 #[test]
2549 fn test_every_anchor_element_on_a_heading_is_a_valid_target() {
2550 for anchor in ["old", "older", "foo"] {
2551 let content = format!(
2552 "# Title\n\n<!-- toc -->\n\n- [Foo](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"old\"></a><a id=\"older\"></a>Foo\n"
2553 );
2554 let result = check_toc(&content);
2555 assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2556 }
2557 }
2558
2559 #[test]
2560 fn test_generated_toc_prefers_the_explicit_html_anchor() {
2561 let toc =
2562 generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n");
2563 assert_eq!(toc, "- [Cheat Sheets](#cheatsheets)");
2564 }
2565
2566 #[test]
2567 fn test_regeneration_keeps_an_existing_anchor_that_reaches_its_heading() {
2568 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";
2571 assert_eq!(
2572 generated_toc(content),
2573 "- [Cheat Sheets](#cheat-sheets)\n- [Other](#other)"
2574 );
2575 }
2576
2577 #[test]
2578 fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
2579 let toc = generated_toc(
2580 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\" data-name=\"pixel\"></a>\n",
2581 );
2582 assert_eq!(toc, "- [Foo](#foo)");
2583
2584 let result = check_toc(
2585 "# Title\n\n<!-- toc -->\n\n- [Foo](#tracking)\n\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\"></a>\n",
2586 );
2587 assert_eq!(result.len(), 1, "{result:?}");
2588 assert!(
2589 result[0].message.contains("Stale entry: 'Foo'"),
2590 "{}",
2591 result[0].message
2592 );
2593 assert!(
2594 result[0].message.contains("Missing entry: 'Foo'"),
2595 "{}",
2596 result[0].message
2597 );
2598 }
2599
2600 #[test]
2601 fn test_an_empty_id_is_not_an_anchor() {
2602 let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"\"></a>\n");
2603 assert_eq!(toc, "- [Foo](#foo)");
2604 }
2605
2606 #[test]
2607 fn test_an_explicit_anchor_still_consumes_the_generated_slug() {
2608 let toc =
2611 generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Same<a id=\"stable\"></a>\n\n## Same\n");
2612 assert_eq!(toc, "- [Same](#stable)\n- [Same](#same-1)");
2613 }
2614
2615 #[test]
2616 fn test_anchor_markup_inside_a_code_span_is_heading_text() {
2617 let heading = "Showing `<a id=\"literal\"></a>` syntax";
2618 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2619 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2620 assert_ne!(slug, "literal");
2621 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2622 }
2623
2624 #[test]
2625 fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
2626 let heading = "Foo <!-- <a id=\"hidden\"></a> -->";
2627 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2628 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2629 assert_ne!(slug, "hidden");
2630 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2631 }
2632
2633 #[test]
2634 fn test_an_explicit_anchor_is_written_as_a_valid_link_destination() {
2635 let headings = "## Alpha<a id=\"foo)bar\"></a>\n\n## Beta<a id=\"has space\"></a>\n";
2639 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{headings}"));
2640 assert_eq!(toc, "- [Alpha](#foo%29bar)\n- [Beta](#has%20space)");
2641
2642 let result = check_toc(&format!(
2643 "# Title\n\n<!-- toc -->\n\n{toc}\n\n<!-- tocstop -->\n\n{headings}"
2644 ));
2645 assert!(result.is_empty(), "{result:?}");
2646 }
2647
2648 #[test]
2649 fn test_regeneration_does_not_encode_an_encoded_entry_twice() {
2650 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";
2653 assert_eq!(generated_toc(content), "- [Alpha](#foo%29bar)");
2654 }
2655
2656 #[test]
2657 fn test_a_backslash_escaped_anchor_element_defines_no_target() {
2658 let heading = "Show \\<a id=\"example\"></a> syntax";
2660 let result = check_toc(&format!(
2661 "# Title\n\n<!-- toc -->\n\n- [{heading}](#example)\n\n<!-- tocstop -->\n\n## {heading}\n"
2662 ));
2663 assert_eq!(result.len(), 1, "{result:?}");
2664 assert!(result[0].message.contains("Stale entry"), "{}", result[0].message);
2665
2666 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2667 let slug = AnchorStyle::GitHub.generate_fragment(heading);
2668 assert_ne!(slug, "example");
2669 assert_eq!(toc, format!("- [{heading}](#{slug})"));
2670 }
2671
2672 #[test]
2673 fn test_a_backslash_escaped_marker_is_text() {
2674 let content = "# Title\n\nWrite these lines:\n\n\\<!-- toc -->\n\nProse that must survive.\n\n\\<!-- tocstop -->\n\n## Alpha\n";
2677 let result = check_toc(content);
2678 assert!(result.is_empty(), "{result:?}");
2679 assert_eq!(create_enabled_rule().fix(&create_ctx(content)).unwrap(), content);
2680
2681 let toc = generated_toc("# Title\n\n\\\\<!-- toc -->\n<!-- tocstop -->\n\n## Alpha\n");
2684 assert_eq!(toc, "- [Alpha](#alpha)");
2685 }
2686
2687 #[test]
2688 fn test_the_whitespace_beside_an_anchor_element_stays_in_the_heading_text() {
2689 let content =
2692 "# Title\n\n<!-- toc -->\n- [Foo Bar](#foo-bar)\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n";
2693 let warnings = check_toc(content);
2694 assert!(warnings.is_empty(), "{warnings:?}");
2695
2696 let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n");
2697 assert_eq!(toc, "- [Foo Bar](#alias)");
2698 }
2699
2700 #[test]
2701 fn test_the_whitespace_an_anchor_element_leaves_at_either_end_is_part_of_the_slug() {
2702 let document = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha-)\n- [Beta](#-beta)\n<!-- tocstop -->\n\n\
2707 ## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n";
2708 let warnings = check_toc(document);
2709 assert!(warnings.is_empty(), "{warnings:?}");
2710
2711 let trimmed = document.replace("#alpha-", "#alpha").replace("#-beta", "#beta");
2712 assert_eq!(
2713 check_toc(&trimmed).len(),
2714 1,
2715 "a TOC written against trimmed slugs is stale"
2716 );
2717
2718 let toc = generated_toc(
2719 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n",
2720 );
2721 assert_eq!(toc, "- [Alpha](#x)\n- [Beta](#y)");
2722 }
2723
2724 #[test]
2725 fn test_a_heading_whose_text_slugs_to_nothing_gets_no_toc_entry() {
2726 let body = "## \n\n## \n\n## Plain\n";
2732 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{body}"));
2733 assert_eq!(toc, "- [Plain](#plain)");
2734
2735 let warnings = check_toc(&format!(
2736 "# Title\n\n<!-- toc -->\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2737 ));
2738 assert!(warnings.is_empty(), "{warnings:?}");
2739
2740 let warnings = check_toc(&format!(
2741 "# Title\n\n<!-- toc -->\n- [img](#img)\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2742 ));
2743 assert_eq!(
2744 warnings.len(),
2745 1,
2746 "an entry for the image's anchor is stale: {warnings:?}"
2747 );
2748 }
2749
2750 #[test]
2751 fn test_an_anchor_inside_another_tags_attribute_value_is_heading_text() {
2752 let toc = generated_toc(
2755 "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <span title='<a id=\"fake\"></a>'>Foo</span>\n",
2756 );
2757 assert_eq!(toc, "- [<span title='<a id=\"fake\"></a>'>Foo</span>](#foo)");
2758 }
2759
2760 #[test]
2761 fn test_a_heading_with_a_custom_id_keeps_its_html_anchor_as_a_target() {
2762 let heading = "<a name=\"old\"></a>My Section {#new}";
2765 let result = check_toc(&format!(
2766 "# Title\n\n<!-- toc -->\n\n- [My Section](#old)\n\n<!-- tocstop -->\n\n## {heading}\n"
2767 ));
2768 assert!(result.is_empty(), "{result:?}");
2769
2770 let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2771 assert_eq!(toc, "- [My Section](#new)");
2772
2773 let result = check_toc(&format!(
2774 "# Title\n\n<!-- toc -->\n\n- [My Section](#my-section)\n\n<!-- tocstop -->\n\n## {heading}\n"
2775 ));
2776 assert_eq!(result.len(), 1, "the generated slug is replaced, got {result:?}");
2777 }
2778}