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