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