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