Skip to main content

rumdl_lib/rules/
md073_toc_validation.rs

1//! MD073: Table of Contents validation rule
2//!
3//! Validates that TOC sections match the actual document headings.
4
5use 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
15/// Regex for TOC start marker: `<!-- toc -->` with optional whitespace variations
16static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
17
18/// Regex for TOC stop marker: `<!-- tocstop -->` or `<!-- /toc -->`
19static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
20
21/// Regex for extracting TOC entries: `- [text](#anchor)` or `* [text](#anchor)`
22/// with optional leading whitespace for nested items
23/// Handles nested brackets like `[`check [PATHS...]`](#check-paths)`
24static TOC_ENTRY_PATTERN: LazyLock<Regex> =
25    LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
26
27/// Represents a detected TOC region in the document
28#[derive(Debug, Clone)]
29struct TocRegion {
30    /// 1-indexed start line of the TOC content (after the marker)
31    start_line: usize,
32    /// 1-indexed end line of the TOC content (before the stop marker)
33    end_line: usize,
34    /// Byte offset where TOC content starts
35    content_start: usize,
36    /// Byte offset where TOC content ends
37    content_end: usize,
38}
39
40/// A parsed TOC entry from the existing TOC
41#[derive(Debug, Clone)]
42struct TocEntry {
43    /// Display text of the link
44    text: String,
45    /// Anchor/fragment (without #)
46    anchor: String,
47    /// Number of leading whitespace characters (for indentation checking)
48    indent_spaces: usize,
49}
50
51/// An expected TOC entry generated from document headings
52#[derive(Debug, Clone)]
53struct ExpectedTocEntry {
54    /// 1-indexed line number of the heading
55    heading_line: usize,
56    /// Heading level (1-6)
57    level: u8,
58    /// Heading text (for display)
59    text: String,
60    /// The fragment a generated entry links to
61    anchor: String,
62    /// Other fragments that also reach the heading. A heading with its own `<a id>`
63    /// keeps the slug generated from its text, so a TOC written against either is
64    /// right.
65    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/// Types of mismatches between actual and expected TOC
75#[derive(Debug)]
76enum TocMismatch {
77    /// Entry exists in TOC but heading doesn't exist
78    StaleEntry { entry: TocEntry },
79    /// Heading exists but no TOC entry for it
80    MissingEntry { expected: ExpectedTocEntry },
81    /// TOC entry text doesn't match heading text
82    TextMismatch {
83        entry: TocEntry,
84        expected: ExpectedTocEntry,
85    },
86    /// TOC entries are in wrong order
87    OrderMismatch { entry: TocEntry, expected_position: usize },
88    /// TOC entry has wrong indentation level
89    IndentationMismatch {
90        entry: TocEntry,
91        actual_indent: usize,
92        expected_indent: usize,
93    },
94}
95
96/// Regex patterns used by `strip_links_and_images`.
97static 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
101/// `anchor` written as the destination of a TOC link.
102///
103/// A bare link destination cannot hold whitespace, control characters or an
104/// unbalanced parenthesis, and a `%` would read as the start of an escape, so
105/// those ASCII characters are percent-encoded. Everything else is written as it
106/// is, so a slug generated from heading text, non-ASCII letters included, is
107/// unchanged.
108fn 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
127/// The fragment a written TOC entry reaches, its percent-encoding undone, as a
128/// browser decodes a fragment before matching it against ids. An escape sequence
129/// that does not decode to UTF-8 leaves the fragment as written.
130fn 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
139/// Extract code-span byte ranges from `text` using the CommonMark rule:
140/// a run of N backticks opens a span closed by exactly N backticks.
141/// Returns a sorted list of `(start, end)` byte offsets that are inside code spans
142/// (including the backtick delimiters themselves).
143fn 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            // Search for the matching closing sequence of exactly n backticks
158            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                        // Convert char indices to byte offsets
168                        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                // No matching close; skip past the opening backticks
181                i = span_start + n;
182            }
183        } else {
184            i += 1;
185        }
186    }
187
188    ranges
189}
190
191/// Strip only links and images from `text`, preserving all other inline
192/// formatting (code spans, bold, italic, etc.).
193///
194/// Links and images cannot appear inside a Markdown link label `[...]`, so they
195/// must be removed when building TOC display text. Code spans and emphasis are
196/// valid inside link labels and should be kept so the TOC entry faithfully
197/// reflects the heading's visual appearance.
198///
199/// Code-span contents are protected: link-like syntax such as `[foo](bar)` that
200/// appears inside backticks is left untouched.
201///
202/// Examples:
203/// - `` `my header` `` → `` `my header` `` (code ticks preserved)
204/// - `[terminal](url)` → `terminal` (link stripped)
205/// - `![alt](img.png)` → `alt` (image stripped)
206/// - `**bold**` → `**bold**` (emphasis preserved)
207/// - `` `[foo](bar)` `` → `` `[foo](bar)` `` (link inside code span preserved)
208/// - `Tool: [terminal](url)` → `Tool: terminal`
209fn strip_links_and_images(text: &str) -> String {
210    // Collect code-span byte ranges so we can protect their contents from
211    // the link/image regex substitutions.
212    let protected = code_span_ranges(text);
213
214    // If there are no code spans the fast path avoids all the extra work.
215    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    // Replace each code span with a unique placeholder that cannot be matched
224    // by the link/image regexes, apply the regexes, then restore the originals.
225    let mut placeholders: Vec<(&str, String)> = Vec::with_capacity(protected.len());
226    let mut masked = text.to_string();
227    // Process spans in reverse order so byte offsets remain valid after each replacement.
228    for (i, &(start, end)) in protected.iter().enumerate().rev() {
229        // Placeholder: a string containing no `[`, `]`, `(`, `)`, `!` characters.
230        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    // Apply link/image stripping to the masked string
237    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    // Restore the original code-span text
242    for (original, placeholder) in &placeholders {
243        masked = masked.replace(placeholder.as_str(), original);
244    }
245
246    masked
247}
248
249/// MD073: Table of Contents Validation
250///
251/// This rule validates that TOC sections match the actual document headings.
252/// It detects TOC regions via markers (`<!-- toc -->...<!-- tocstop -->`).
253///
254/// To opt into TOC validation, add markers to your document:
255/// ```markdown
256/// <!-- toc -->
257/// - [Section](#section)
258/// <!-- tocstop -->
259/// ```
260///
261/// ## Configuration
262///
263/// ```toml
264/// [MD073]
265/// # Enable the rule (opt-in, disabled by default)
266/// enabled = true
267/// # Minimum heading level to include (default: 2)
268/// min-level = 2
269/// # Maximum heading level to include (default: 4)
270/// max-level = 4
271/// # Whether TOC order must match document order (default: true)
272/// enforce-order = true
273/// # Indent size per nesting level (default: from MD007 config, or 2)
274/// indent = 2
275/// ```
276#[derive(Clone)]
277pub struct MD073TocValidation {
278    /// Whether this rule is enabled (default: false - opt-in rule)
279    enabled: bool,
280    /// Minimum heading level to include
281    min_level: u8,
282    /// Maximum heading level to include
283    max_level: u8,
284    /// Whether to enforce order matching
285    enforce_order: bool,
286    /// Indent size per nesting level (reads from MD007 config by default)
287    pub indent: usize,
288}
289
290impl Default for MD073TocValidation {
291    fn default() -> Self {
292        Self {
293            enabled: false, // Disabled by default - opt-in rule
294            min_level: 2,
295            max_level: 4,
296            enforce_order: true,
297            indent: 2, // Default indent, can be overridden by MD007 config
298        }
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    /// Create a new rule with default settings
316    pub fn new() -> Self {
317        Self::default()
318    }
319
320    /// Whether `content` contains a TOC marker that is Markdown syntax rather
321    /// than literal text. A marker inside an inline code span is code, and one
322    /// whose `<` is backslash-escaped renders as the characters themselves.
323    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    /// Detect TOC region using markers
331    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            // Skip if in code block or front matter
340            if line_info.in_code_block || line_info.in_front_matter {
341                continue;
342            }
343
344            // Look for start marker or stop marker
345            if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
346                // We have a start, now look for stop marker
347                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                    // Handle case where there's no content between markers
352                    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                // TOC content starts on the next line
370                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    /// Detect TOC region using markers
381    fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
382        self.detect_by_markers(ctx)
383    }
384
385    /// Extract TOC entries from the detected region
386    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    /// Build expected TOC entries from document headings
410    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            // Skip headings before/within the TOC region
418            if line_num <= toc_region.end_line {
419                // Also skip the TOC heading itself for heading-based detection
420                continue;
421            }
422
423            // Skip code blocks, front matter, HTML blocks
424            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                // Filter by min/max level
430                if heading.level < self.min_level || heading.level > self.max_level {
431                    continue;
432                }
433
434                // A custom ID ({#id}) replaces the generated slug. An HTML anchor
435                // element beside the heading adds a target either way: the
436                // renderer keeps the element's own id, and without a custom ID it
437                // still generates the slug from the text, which still counts
438                // towards the numbering of later duplicates.
439                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                            // A heading whose text slugs to nothing (an image, an
455                            // emoji) gets no anchor from GitHub, only a number on a
456                            // repeat, so without an element of its own no TOC entry
457                            // can reach it. It still counts towards the numbering.
458                            None if slug.is_empty() => continue,
459                            None => (generated, Vec::new()),
460                        }
461                    }
462                };
463
464                entries.push(ExpectedTocEntry {
465                    heading_line: line_num,
466                    level: heading.level,
467                    text: heading.text.clone(),
468                    anchor,
469                    aliases,
470                });
471            }
472        }
473
474        entries
475    }
476
477    /// The fragment a renderer gives a heading whose base fragment has already
478    /// been seen `n` times: `base`, then `base-1`, `base-2`, ...
479    fn deduplicate_fragment(fragment_counts: &mut HashMap<String, usize>, base: &str) -> String {
480        match fragment_counts.get_mut(base) {
481            Some(count) => {
482                let suffix = *count;
483                *count += 1;
484                format!("{base}-{suffix}")
485            }
486            None => {
487                fragment_counts.insert(base.to_string(), 1);
488                base.to_string()
489            }
490        }
491    }
492
493    /// Pair each actual TOC entry with the first still-unclaimed heading its
494    /// anchor reaches. `None` marks an entry that reaches no heading.
495    fn match_entries(actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<Option<usize>> {
496        let mut claimed = vec![false; expected.len()];
497        actual
498            .iter()
499            .map(|entry| {
500                let anchor = decode_fragment(&entry.anchor);
501                let found = expected
502                    .iter()
503                    .enumerate()
504                    .position(|(idx, exp)| !claimed[idx] && exp.is_reached_by(&anchor));
505                if let Some(idx) = found {
506                    claimed[idx] = true;
507                }
508                found
509            })
510            .collect()
511    }
512
513    /// Compare actual TOC entries against expected and find mismatches.
514    ///
515    /// `matching` pairs each actual entry with the heading it reaches, as
516    /// computed by [`Self::match_entries`].
517    fn validate_toc(
518        &self,
519        actual: &[TocEntry],
520        expected: &[ExpectedTocEntry],
521        matching: &[Option<usize>],
522    ) -> Vec<TocMismatch> {
523        let mut mismatches = Vec::new();
524        let pairs = || {
525            actual
526                .iter()
527                .zip(matching)
528                .enumerate()
529                .filter_map(|(actual_idx, (entry, matched))| matched.map(|exp_idx| (actual_idx, entry, exp_idx)))
530        };
531
532        // Stale entries reach no heading.
533        for (entry, matched) in actual.iter().zip(matching) {
534            if matched.is_none() {
535                mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
536            }
537        }
538
539        // Missing entries are headings no TOC entry reaches.
540        for (exp_idx, exp) in expected.iter().enumerate() {
541            if !matching.contains(&Some(exp_idx)) {
542                mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
543            }
544        }
545
546        // Check for text mismatches. Compare with the same normalization used in
547        // generate_toc: strip only links and images, preserve code spans and emphasis.
548        // This ensures a correct user-written TOC entry like `` [`my header`](#anchor) ``
549        // is not flagged against a heading `` `my header` ``.
550        let mut text_mismatched = vec![false; actual.len()];
551        for (actual_idx, entry, exp_idx) in pairs() {
552            let exp = &expected[exp_idx];
553            let actual_normalized = strip_links_and_images(entry.text.trim());
554            let expected_normalized = strip_links_and_images(exp.text.trim());
555            if actual_normalized != expected_normalized {
556                text_mismatched[actual_idx] = true;
557                mismatches.push(TocMismatch::TextMismatch {
558                    entry: entry.clone(),
559                    expected: exp.clone(),
560                });
561            }
562        }
563
564        // Check for indentation mismatches
565        // Expected indentation is indent spaces per level difference from base level
566        if !expected.is_empty() {
567            let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
568
569            for (actual_idx, entry, exp_idx) in pairs() {
570                let level_diff = expected[exp_idx].level.saturating_sub(base_level) as usize;
571                let expected_indent = level_diff * self.indent;
572
573                // An entry already reported for its text is not reported again
574                if entry.indent_spaces != expected_indent && !text_mismatched[actual_idx] {
575                    mismatches.push(TocMismatch::IndentationMismatch {
576                        entry: entry.clone(),
577                        actual_indent: entry.indent_spaces,
578                        expected_indent,
579                    });
580                }
581            }
582        }
583
584        // Check order if enforce_order is enabled: walk the headings alongside the
585        // matched entries, and an entry whose heading lies behind the walk is out
586        // of order.
587        if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
588            let mut expected_idx = 0;
589            for (actual_idx, entry, exp_idx) in pairs() {
590                if exp_idx >= expected_idx {
591                    expected_idx = exp_idx + 1;
592                } else if !text_mismatched[actual_idx] {
593                    mismatches.push(TocMismatch::OrderMismatch {
594                        entry: entry.clone(),
595                        expected_position: exp_idx + 1,
596                    });
597                }
598            }
599        }
600
601        mismatches
602    }
603
604    /// Expected entries as they should be regenerated: a TOC entry that already
605    /// reaches its heading keeps the anchor it was written with. The anchor is
606    /// stored decoded, as every expected anchor is, and encoded again on output.
607    fn keep_reached_anchors(
608        actual: &[TocEntry],
609        expected: &[ExpectedTocEntry],
610        matching: &[Option<usize>],
611    ) -> Vec<ExpectedTocEntry> {
612        let mut regenerated = expected.to_vec();
613        for (entry, exp_idx) in actual
614            .iter()
615            .zip(matching)
616            .filter_map(|(entry, m)| m.map(|idx| (entry, idx)))
617        {
618            regenerated[exp_idx].anchor = decode_fragment(&entry.anchor).into_owned();
619        }
620        regenerated
621    }
622
623    /// Generate a new TOC from expected entries (always uses nested indentation)
624    fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
625        if expected.is_empty() {
626            return String::new();
627        }
628
629        let mut result = String::new();
630        let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
631        let indent_str = " ".repeat(self.indent);
632
633        for entry in expected {
634            let level_diff = entry.level.saturating_sub(base_level) as usize;
635            let indent = indent_str.repeat(level_diff);
636
637            // Build display text: strip only links and images (which would create invalid
638            // nested-link syntax inside `[...]`), but preserve code spans and emphasis so
639            // the TOC entry reflects the heading's visual appearance.
640            let display_text = strip_links_and_images(&entry.text);
641            let fragment = encode_fragment(&entry.anchor);
642            result.push_str(&format!("{indent}- [{display_text}](#{fragment})\n"));
643        }
644
645        result
646    }
647}
648
649impl Rule for MD073TocValidation {
650    fn name(&self) -> &'static str {
651        "MD073"
652    }
653
654    fn description(&self) -> &'static str {
655        "Table of Contents should match document headings"
656    }
657
658    fn should_skip(&self, ctx: &LintContext) -> bool {
659        // Quick check: skip if no TOC markers. detect_toc_region() is
660        // case-insensitive, so use a case-insensitive containment check here
661        // to avoid skipping fix() on documents with uppercase markers like
662        // `<!-- TOC -->`.
663        let lower = ctx.content.to_ascii_lowercase();
664        !(lower.contains("<!-- toc") || lower.contains("<!--toc"))
665    }
666
667    fn check(&self, ctx: &LintContext) -> LintResult {
668        let mut warnings = Vec::new();
669
670        // Detect TOC region
671        let Some(region) = self.detect_toc_region(ctx) else {
672            // No TOC found - nothing to validate
673            return Ok(warnings);
674        };
675
676        // Extract actual TOC entries
677        let actual_entries = self.extract_toc_entries(ctx, &region);
678
679        // Build expected TOC from headings
680        let expected_entries = self.build_expected_toc(ctx, &region);
681
682        // If no expected entries and no actual entries, nothing to validate
683        if expected_entries.is_empty() && actual_entries.is_empty() {
684            return Ok(warnings);
685        }
686
687        // Validate
688        let matching = Self::match_entries(&actual_entries, &expected_entries);
689        let mismatches = self.validate_toc(&actual_entries, &expected_entries, &matching);
690
691        if !mismatches.is_empty() {
692            // Generate a single warning at the TOC region with details
693            let mut details = Vec::new();
694
695            for mismatch in &mismatches {
696                match mismatch {
697                    TocMismatch::StaleEntry { entry } => {
698                        details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
699                    }
700                    TocMismatch::MissingEntry { expected } => {
701                        details.push(format!(
702                            "Missing entry: '{}' (line {})",
703                            expected.text, expected.heading_line
704                        ));
705                    }
706                    TocMismatch::TextMismatch { entry, expected } => {
707                        details.push(format!(
708                            "Text mismatch: TOC has '{}', heading is '{}'",
709                            entry.text, expected.text
710                        ));
711                    }
712                    TocMismatch::OrderMismatch {
713                        entry,
714                        expected_position,
715                    } => {
716                        details.push(format!(
717                            "Order mismatch: '{}' should be at position {}",
718                            entry.text, expected_position
719                        ));
720                    }
721                    TocMismatch::IndentationMismatch {
722                        entry,
723                        actual_indent,
724                        expected_indent,
725                        ..
726                    } => {
727                        details.push(format!(
728                            "Indentation mismatch: '{}' has {} spaces, expected {} spaces",
729                            entry.text, actual_indent, expected_indent
730                        ));
731                    }
732                }
733            }
734
735            let message = format!(
736                "Table of Contents does not match document headings: {}",
737                details.join("; ")
738            );
739
740            // Generate fix: replace entire TOC content
741            let regenerated = Self::keep_reached_anchors(&actual_entries, &expected_entries, &matching);
742            let new_toc = self.generate_toc(&regenerated);
743            let fix_range = region.content_start..region.content_end;
744
745            warnings.push(LintWarning {
746                rule_name: Some(self.name().to_string()),
747                message,
748                line: region.start_line,
749                column: 1,
750                end_line: region.end_line,
751                end_column: 1,
752                severity: Severity::Warning,
753                fix: Some(Fix::new(fix_range, new_toc)),
754            });
755        }
756
757        Ok(warnings)
758    }
759
760    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
761        if self.should_skip(ctx) {
762            return Ok(ctx.content.to_string());
763        }
764        let warnings = self.check(ctx)?;
765        if warnings.is_empty() {
766            return Ok(ctx.content.to_string());
767        }
768        let warnings =
769            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
770        crate::utils::fix_utils::apply_warning_fixes(ctx.content, &warnings).map_err(LintError::InvalidInput)
771    }
772
773    fn category(&self) -> RuleCategory {
774        RuleCategory::Other
775    }
776
777    fn as_any(&self) -> &dyn std::any::Any {
778        self
779    }
780
781    fn default_config_section(&self) -> Option<(String, toml::Value)> {
782        let value: toml::Value = toml::from_str(
783            r#"
784# Whether this rule is enabled (opt-in, disabled by default)
785enabled = false
786# Minimum heading level to include
787min-level = 2
788# Maximum heading level to include
789max-level = 4
790# Whether TOC order must match document order
791enforce-order = true
792# Indentation per nesting level (defaults to MD007's indent value)
793indent = 2
794"#,
795        )
796        .ok()?;
797        Some(("MD073".to_string(), value))
798    }
799
800    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
801    where
802        Self: Sized,
803    {
804        let mut rule = MD073TocValidation::default();
805        let mut indent_from_md073 = false;
806
807        if let Some(rule_config) = config.rules.get("MD073") {
808            // Parse enabled (opt-in rule, defaults to false)
809            if let Some(enabled) = rule_config.values.get("enabled").and_then(toml::Value::as_bool) {
810                rule.enabled = enabled;
811            }
812
813            // Parse min-level
814            if let Some(min_level) = rule_config.values.get("min-level").and_then(toml::Value::as_integer) {
815                rule.min_level = (min_level.clamp(1, 6)) as u8;
816            }
817
818            // Parse max-level
819            if let Some(max_level) = rule_config.values.get("max-level").and_then(toml::Value::as_integer) {
820                rule.max_level = (max_level.clamp(1, 6)) as u8;
821            }
822
823            // Parse enforce-order
824            if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(toml::Value::as_bool) {
825                rule.enforce_order = enforce_order;
826            }
827
828            // Parse indent (MD073-specific override)
829            if let Some(indent) = rule_config.values.get("indent").and_then(toml::Value::as_integer) {
830                rule.indent = (indent.clamp(1, 8)) as usize;
831                indent_from_md073 = true;
832            }
833        }
834
835        // If indent not explicitly set in MD073, read from MD007 config
836        if !indent_from_md073
837            && let Some(md007_config) = config.rules.get("MD007")
838            && let Some(indent) = md007_config.values.get("indent").and_then(toml::Value::as_integer)
839        {
840            rule.indent = (indent.clamp(1, 8)) as usize;
841        }
842
843        Box::new(rule)
844    }
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::config::MarkdownFlavor;
851    use regex::Regex;
852    use std::sync::LazyLock;
853
854    // ---- Test-only helpers for stripping all inline formatting ----
855    // These are not used in production code; they exist only to test
856    // the individual stripping primitives in isolation.
857
858    /// Strip code spans from text, handling multi-backtick spans per CommonMark spec.
859    fn strip_code_spans(text: &str) -> String {
860        let chars: Vec<char> = text.chars().collect();
861        let len = chars.len();
862        let mut result = String::with_capacity(text.len());
863        let mut i = 0;
864
865        while i < len {
866            if chars[i] == '`' {
867                let open_start = i;
868                while i < len && chars[i] == '`' {
869                    i += 1;
870                }
871                let backtick_count = i - open_start;
872
873                let content_start = i;
874                let mut found_close = false;
875                while i < len {
876                    if chars[i] == '`' {
877                        let close_start = i;
878                        while i < len && chars[i] == '`' {
879                            i += 1;
880                        }
881                        if i - close_start == backtick_count {
882                            let content: String = chars[content_start..close_start].iter().collect();
883                            let stripped = if content.starts_with(' ') && content.ends_with(' ') && content.len() > 1 {
884                                content[1..content.len() - 1].to_string()
885                            } else {
886                                content
887                            };
888                            result.push_str(&stripped);
889                            found_close = true;
890                            break;
891                        }
892                    } else {
893                        i += 1;
894                    }
895                }
896                if !found_close {
897                    for _ in 0..backtick_count {
898                        result.push('`');
899                    }
900                    let remaining: String = chars[content_start..].iter().collect();
901                    result.push_str(&remaining);
902                    break;
903                }
904            } else {
905                result.push(chars[i]);
906                i += 1;
907            }
908        }
909
910        result
911    }
912
913    static TEST_BOLD_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*\*([^*]+)\*\*").unwrap());
914    static TEST_BOLD_UNDERSCORE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"__([^_]+)__").unwrap());
915    static TEST_ITALIC_ASTERISK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\*([^*]+)\*").unwrap());
916    static TEST_ITALIC_UNDERSCORE: LazyLock<Regex> =
917        LazyLock::new(|| Regex::new(r"(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)").unwrap());
918
919    /// Strip all inline markdown formatting from text, reducing it to plain text.
920    /// Builds on `strip_links_and_images` and additionally removes code spans,
921    /// bold, and italic markers. Used in tests only.
922    fn strip_markdown_formatting(text: &str) -> String {
923        let mut result = strip_links_and_images(text);
924        result = strip_code_spans(&result);
925        result = TEST_BOLD_ASTERISK.replace_all(&result, "$1").to_string();
926        result = TEST_BOLD_UNDERSCORE.replace_all(&result, "$1").to_string();
927        result = TEST_ITALIC_ASTERISK.replace_all(&result, "$1").to_string();
928        result = TEST_ITALIC_UNDERSCORE.replace_all(&result, "$1$2$3").to_string();
929        result
930    }
931
932    fn create_ctx(content: &str) -> LintContext<'_> {
933        LintContext::new(content, MarkdownFlavor::Standard, None)
934    }
935
936    /// Create rule with enabled=true for tests that call check() directly
937    fn create_enabled_rule() -> MD073TocValidation {
938        MD073TocValidation {
939            enabled: true,
940            ..MD073TocValidation::default()
941        }
942    }
943
944    // ========== Detection Tests ==========
945
946    #[test]
947    fn test_detect_markers_basic() {
948        let rule = MD073TocValidation::new();
949        let content = r#"# Title
950
951<!-- toc -->
952
953- [Heading 1](#heading-1)
954
955<!-- tocstop -->
956
957## Heading 1
958
959Content here.
960"#;
961        let ctx = create_ctx(content);
962        let region = rule.detect_by_markers(&ctx);
963        assert!(region.is_some());
964        let region = region.unwrap();
965        // Verify region boundaries are detected correctly
966        assert_eq!(region.start_line, 4);
967        assert_eq!(region.end_line, 6);
968    }
969
970    #[test]
971    fn test_detect_markers_variations() {
972        let rule = MD073TocValidation::new();
973
974        // Test <!--toc--> (no spaces)
975        let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
976        let ctx1 = create_ctx(content1);
977        assert!(rule.detect_by_markers(&ctx1).is_some());
978
979        // Test <!-- TOC --> (uppercase)
980        let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
981        let ctx2 = create_ctx(content2);
982        assert!(rule.detect_by_markers(&ctx2).is_some());
983
984        // Test <!-- /toc --> (alternative stop marker)
985        let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
986        let ctx3 = create_ctx(content3);
987        assert!(rule.detect_by_markers(&ctx3).is_some());
988    }
989
990    #[test]
991    fn test_no_toc_region() {
992        let rule = MD073TocValidation::new();
993        let content = r#"# Title
994
995## Heading 1
996
997Content here.
998
999## Heading 2
1000
1001More content.
1002"#;
1003        let ctx = create_ctx(content);
1004        let region = rule.detect_toc_region(&ctx);
1005        assert!(region.is_none());
1006    }
1007
1008    // ========== Validation Tests ==========
1009
1010    #[test]
1011    fn test_toc_matches_headings() {
1012        let rule = create_enabled_rule();
1013        let content = r#"# Title
1014
1015<!-- toc -->
1016
1017- [Heading 1](#heading-1)
1018- [Heading 2](#heading-2)
1019
1020<!-- tocstop -->
1021
1022## Heading 1
1023
1024Content.
1025
1026## Heading 2
1027
1028More content.
1029"#;
1030        let ctx = create_ctx(content);
1031        let result = rule.check(&ctx).unwrap();
1032        assert!(result.is_empty(), "Expected no warnings for matching TOC");
1033    }
1034
1035    #[test]
1036    fn test_missing_entry() {
1037        let rule = create_enabled_rule();
1038        let content = r#"# Title
1039
1040<!-- toc -->
1041
1042- [Heading 1](#heading-1)
1043
1044<!-- tocstop -->
1045
1046## Heading 1
1047
1048Content.
1049
1050## Heading 2
1051
1052New heading not in TOC.
1053"#;
1054        let ctx = create_ctx(content);
1055        let result = rule.check(&ctx).unwrap();
1056        assert_eq!(result.len(), 1);
1057        assert!(result[0].message.contains("Missing entry"));
1058        assert!(result[0].message.contains("Heading 2"));
1059    }
1060
1061    #[test]
1062    fn test_stale_entry() {
1063        let rule = create_enabled_rule();
1064        let content = r#"# Title
1065
1066<!-- toc -->
1067
1068- [Heading 1](#heading-1)
1069- [Deleted Heading](#deleted-heading)
1070
1071<!-- tocstop -->
1072
1073## Heading 1
1074
1075Content.
1076"#;
1077        let ctx = create_ctx(content);
1078        let result = rule.check(&ctx).unwrap();
1079        assert_eq!(result.len(), 1);
1080        assert!(result[0].message.contains("Stale entry"));
1081        assert!(result[0].message.contains("Deleted Heading"));
1082    }
1083
1084    #[test]
1085    fn test_text_mismatch() {
1086        let rule = create_enabled_rule();
1087        let content = r#"# Title
1088
1089<!-- toc -->
1090
1091- [Old Name](#heading-1)
1092
1093<!-- tocstop -->
1094
1095## Heading 1
1096
1097Content.
1098"#;
1099        let ctx = create_ctx(content);
1100        let result = rule.check(&ctx).unwrap();
1101        assert_eq!(result.len(), 1);
1102        assert!(result[0].message.contains("Text mismatch"));
1103    }
1104
1105    // ========== Level Filtering Tests ==========
1106
1107    #[test]
1108    fn test_min_level_excludes_h1() {
1109        let mut rule = MD073TocValidation::new();
1110        rule.min_level = 2;
1111
1112        let content = r#"<!-- toc -->
1113
1114<!-- tocstop -->
1115
1116# Should Be Excluded
1117
1118## Should Be Included
1119
1120Content.
1121"#;
1122        let ctx = create_ctx(content);
1123        let region = rule.detect_toc_region(&ctx).unwrap();
1124        let expected = rule.build_expected_toc(&ctx, &region);
1125
1126        assert_eq!(expected.len(), 1);
1127        assert_eq!(expected[0].text, "Should Be Included");
1128    }
1129
1130    #[test]
1131    fn test_max_level_excludes_h5_h6() {
1132        let mut rule = MD073TocValidation::new();
1133        rule.max_level = 4;
1134
1135        let content = r#"<!-- toc -->
1136
1137<!-- tocstop -->
1138
1139## Level 2
1140
1141### Level 3
1142
1143#### Level 4
1144
1145##### Level 5 Should Be Excluded
1146
1147###### Level 6 Should Be Excluded
1148"#;
1149        let ctx = create_ctx(content);
1150        let region = rule.detect_toc_region(&ctx).unwrap();
1151        let expected = rule.build_expected_toc(&ctx, &region);
1152
1153        assert_eq!(expected.len(), 3);
1154        assert!(expected.iter().all(|e| e.level <= 4));
1155    }
1156
1157    // ========== Fix Tests ==========
1158
1159    #[test]
1160    fn test_fix_adds_missing_entry() {
1161        let rule = MD073TocValidation::new();
1162        let content = r#"# Title
1163
1164<!-- toc -->
1165
1166- [Heading 1](#heading-1)
1167
1168<!-- tocstop -->
1169
1170## Heading 1
1171
1172Content.
1173
1174## Heading 2
1175
1176New heading.
1177"#;
1178        let ctx = create_ctx(content);
1179        let fixed = rule.fix(&ctx).unwrap();
1180        assert!(fixed.contains("- [Heading 2](#heading-2)"));
1181    }
1182
1183    #[test]
1184    fn test_fix_removes_stale_entry() {
1185        let rule = MD073TocValidation::new();
1186        let content = r#"# Title
1187
1188<!-- toc -->
1189
1190- [Heading 1](#heading-1)
1191- [Deleted](#deleted)
1192
1193<!-- tocstop -->
1194
1195## Heading 1
1196
1197Content.
1198"#;
1199        let ctx = create_ctx(content);
1200        let fixed = rule.fix(&ctx).unwrap();
1201        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1202        assert!(!fixed.contains("Deleted"));
1203    }
1204
1205    #[test]
1206    fn test_fix_idempotent() {
1207        let rule = MD073TocValidation::new();
1208        let content = r#"# Title
1209
1210<!-- toc -->
1211
1212- [Heading 1](#heading-1)
1213- [Heading 2](#heading-2)
1214
1215<!-- tocstop -->
1216
1217## Heading 1
1218
1219Content.
1220
1221## Heading 2
1222
1223More.
1224"#;
1225        let ctx = create_ctx(content);
1226        let fixed1 = rule.fix(&ctx).unwrap();
1227        let ctx2 = create_ctx(&fixed1);
1228        let fixed2 = rule.fix(&ctx2).unwrap();
1229
1230        // Second fix should produce same output
1231        assert_eq!(fixed1, fixed2);
1232    }
1233
1234    #[test]
1235    fn test_fix_preserves_markers() {
1236        let rule = MD073TocValidation::new();
1237        let content = r#"# Title
1238
1239<!-- toc -->
1240
1241Old TOC content.
1242
1243<!-- tocstop -->
1244
1245## New Heading
1246
1247Content.
1248"#;
1249        let ctx = create_ctx(content);
1250        let fixed = rule.fix(&ctx).unwrap();
1251
1252        // Markers should still be present
1253        assert!(fixed.contains("<!-- toc -->"));
1254        assert!(fixed.contains("<!-- tocstop -->"));
1255        // New content should be generated
1256        assert!(fixed.contains("- [New Heading](#new-heading)"));
1257    }
1258
1259    #[test]
1260    fn test_fix_requires_markers() {
1261        let rule = create_enabled_rule();
1262
1263        // Document without markers - no TOC detected, no changes
1264        let content_no_markers = r#"# Title
1265
1266## Heading 1
1267
1268Content.
1269"#;
1270        let ctx = create_ctx(content_no_markers);
1271        let fixed = rule.fix(&ctx).unwrap();
1272        assert_eq!(fixed, content_no_markers);
1273
1274        // Document with markers - TOC detected and fixed
1275        let content_markers = r#"# Title
1276
1277<!-- toc -->
1278
1279- [Old Entry](#old-entry)
1280
1281<!-- tocstop -->
1282
1283## Heading 1
1284
1285Content.
1286"#;
1287        let ctx = create_ctx(content_markers);
1288        let fixed = rule.fix(&ctx).unwrap();
1289        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1290        assert!(!fixed.contains("Old Entry"));
1291    }
1292
1293    // ========== Anchor Tests ==========
1294
1295    #[test]
1296    fn test_duplicate_heading_anchors() {
1297        let rule = MD073TocValidation::new();
1298        let content = r#"# Title
1299
1300<!-- toc -->
1301
1302<!-- tocstop -->
1303
1304## Duplicate
1305
1306Content.
1307
1308## Duplicate
1309
1310More content.
1311
1312## Duplicate
1313
1314Even more.
1315"#;
1316        let ctx = create_ctx(content);
1317        let region = rule.detect_toc_region(&ctx).unwrap();
1318        let expected = rule.build_expected_toc(&ctx, &region);
1319
1320        assert_eq!(expected.len(), 3);
1321        assert_eq!(expected[0].anchor, "duplicate");
1322        assert_eq!(expected[1].anchor, "duplicate-1");
1323        assert_eq!(expected[2].anchor, "duplicate-2");
1324    }
1325
1326    // ========== Edge Cases ==========
1327
1328    #[test]
1329    fn test_headings_in_code_blocks_ignored() {
1330        let rule = create_enabled_rule();
1331        let content = r#"# Title
1332
1333<!-- toc -->
1334
1335- [Real Heading](#real-heading)
1336
1337<!-- tocstop -->
1338
1339## Real Heading
1340
1341```markdown
1342## Fake Heading In Code
1343```
1344
1345Content.
1346"#;
1347        let ctx = create_ctx(content);
1348        let result = rule.check(&ctx).unwrap();
1349        assert!(result.is_empty(), "Should not report fake heading in code block");
1350    }
1351
1352    #[test]
1353    fn test_empty_toc_region() {
1354        let rule = create_enabled_rule();
1355        let content = r#"# Title
1356
1357<!-- toc -->
1358<!-- tocstop -->
1359
1360## Heading 1
1361
1362Content.
1363"#;
1364        let ctx = create_ctx(content);
1365        let result = rule.check(&ctx).unwrap();
1366        assert_eq!(result.len(), 1);
1367        assert!(result[0].message.contains("Missing entry"));
1368    }
1369
1370    #[test]
1371    fn test_nested_indentation() {
1372        let rule = create_enabled_rule();
1373
1374        let content = r#"<!-- toc -->
1375
1376<!-- tocstop -->
1377
1378## Level 2
1379
1380### Level 3
1381
1382#### Level 4
1383
1384## Another Level 2
1385"#;
1386        let ctx = create_ctx(content);
1387        let region = rule.detect_toc_region(&ctx).unwrap();
1388        let expected = rule.build_expected_toc(&ctx, &region);
1389        let toc = rule.generate_toc(&expected);
1390
1391        // Check indentation (always nested)
1392        assert!(toc.contains("- [Level 2](#level-2)"));
1393        assert!(toc.contains("  - [Level 3](#level-3)"));
1394        assert!(toc.contains("    - [Level 4](#level-4)"));
1395        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1396    }
1397
1398    // ========== Indentation Mismatch Tests ==========
1399
1400    #[test]
1401    fn test_indentation_mismatch_detected() {
1402        let rule = create_enabled_rule();
1403        // TOC entries are all at same indentation level, but headings have different levels
1404        let content = r#"<!-- toc -->
1405- [Hello](#hello)
1406- [Another](#another)
1407- [Heading](#heading)
1408<!-- tocstop -->
1409
1410## Hello
1411
1412### Another
1413
1414## Heading
1415"#;
1416        let ctx = create_ctx(content);
1417        let result = rule.check(&ctx).unwrap();
1418        // Should detect indentation mismatch - "Another" is level 3 but has no indent
1419        assert_eq!(result.len(), 1, "Should report indentation mismatch: {result:?}");
1420        assert!(
1421            result[0].message.contains("Indentation mismatch"),
1422            "Message should mention indentation: {}",
1423            result[0].message
1424        );
1425        assert!(
1426            result[0].message.contains("Another"),
1427            "Message should mention the entry: {}",
1428            result[0].message
1429        );
1430    }
1431
1432    #[test]
1433    fn test_indentation_mismatch_fixed() {
1434        let rule = create_enabled_rule();
1435        // TOC entries are all at same indentation level, but headings have different levels
1436        let content = r#"<!-- toc -->
1437- [Hello](#hello)
1438- [Another](#another)
1439- [Heading](#heading)
1440<!-- tocstop -->
1441
1442## Hello
1443
1444### Another
1445
1446## Heading
1447"#;
1448        let ctx = create_ctx(content);
1449        let fixed = rule.fix(&ctx).unwrap();
1450        // After fix, "Another" should be indented
1451        assert!(fixed.contains("- [Hello](#hello)"));
1452        assert!(fixed.contains("  - [Another](#another)")); // Indented with 2 spaces
1453        assert!(fixed.contains("- [Heading](#heading)"));
1454    }
1455
1456    #[test]
1457    fn test_no_indentation_mismatch_when_correct() {
1458        let rule = create_enabled_rule();
1459        // TOC has correct indentation
1460        let content = r#"<!-- toc -->
1461- [Hello](#hello)
1462  - [Another](#another)
1463- [Heading](#heading)
1464<!-- tocstop -->
1465
1466## Hello
1467
1468### Another
1469
1470## Heading
1471"#;
1472        let ctx = create_ctx(content);
1473        let result = rule.check(&ctx).unwrap();
1474        // Should not report any issues - indentation is correct
1475        assert!(result.is_empty(), "Should not report issues: {result:?}");
1476    }
1477
1478    // ========== Order Mismatch Tests ==========
1479
1480    #[test]
1481    fn test_order_mismatch_detected() {
1482        let rule = create_enabled_rule();
1483        let content = r#"# Title
1484
1485<!-- toc -->
1486
1487- [Section B](#section-b)
1488- [Section A](#section-a)
1489
1490<!-- tocstop -->
1491
1492## Section A
1493
1494Content A.
1495
1496## Section B
1497
1498Content B.
1499"#;
1500        let ctx = create_ctx(content);
1501        let result = rule.check(&ctx).unwrap();
1502        // Should detect order mismatch - Section B appears before Section A in TOC
1503        // but Section A comes first in document
1504        assert!(!result.is_empty(), "Should detect order mismatch");
1505    }
1506
1507    #[test]
1508    fn test_order_mismatch_ignored_when_disabled() {
1509        let mut rule = create_enabled_rule();
1510        rule.enforce_order = false;
1511        let content = r#"# Title
1512
1513<!-- toc -->
1514
1515- [Section B](#section-b)
1516- [Section A](#section-a)
1517
1518<!-- tocstop -->
1519
1520## Section A
1521
1522Content A.
1523
1524## Section B
1525
1526Content B.
1527"#;
1528        let ctx = create_ctx(content);
1529        let result = rule.check(&ctx).unwrap();
1530        // With enforce_order=false, order mismatches should be ignored
1531        assert!(result.is_empty(), "Should not report order mismatch when disabled");
1532    }
1533
1534    // ========== Unicode and Special Characters Tests ==========
1535
1536    #[test]
1537    fn test_unicode_headings() {
1538        let rule = create_enabled_rule();
1539        let content = r#"# Title
1540
1541<!-- toc -->
1542
1543- [日本語の見出し](#日本語の見出し)
1544- [Émojis 🎉](#émojis-)
1545
1546<!-- tocstop -->
1547
1548## 日本語の見出し
1549
1550Japanese content.
1551
1552## Émojis 🎉
1553
1554Content with emojis.
1555"#;
1556        let ctx = create_ctx(content);
1557        let result = rule.check(&ctx).unwrap();
1558        // Should handle unicode correctly
1559        assert!(result.is_empty(), "Should handle unicode headings");
1560    }
1561
1562    #[test]
1563    fn test_special_characters_in_headings() {
1564        let rule = create_enabled_rule();
1565        let content = r#"# Title
1566
1567<!-- toc -->
1568
1569- [What's New?](#whats-new)
1570- [C++ Guide](#c-guide)
1571
1572<!-- tocstop -->
1573
1574## What's New?
1575
1576News content.
1577
1578## C++ Guide
1579
1580C++ content.
1581"#;
1582        let ctx = create_ctx(content);
1583        let result = rule.check(&ctx).unwrap();
1584        assert!(result.is_empty(), "Should handle special characters");
1585    }
1586
1587    #[test]
1588    fn test_code_spans_in_headings() {
1589        let rule = create_enabled_rule();
1590        let content = r#"# Title
1591
1592<!-- toc -->
1593
1594- [`check [PATHS...]`](#check-paths)
1595
1596<!-- tocstop -->
1597
1598## `check [PATHS...]`
1599
1600Command documentation.
1601"#;
1602        let ctx = create_ctx(content);
1603        let result = rule.check(&ctx).unwrap();
1604        assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1605    }
1606
1607    // ========== Config Tests ==========
1608
1609    #[test]
1610    fn test_from_config_defaults() {
1611        let config = crate::config::Config::default();
1612        let rule = MD073TocValidation::from_config(&config);
1613        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1614
1615        assert_eq!(rule.min_level, 2);
1616        assert_eq!(rule.max_level, 4);
1617        assert!(rule.enforce_order);
1618        assert_eq!(rule.indent, 2);
1619    }
1620
1621    #[test]
1622    fn test_indent_from_md007_config() {
1623        use crate::config::{Config, RuleConfig};
1624        use std::collections::BTreeMap;
1625
1626        let mut config = Config::default();
1627
1628        // Set MD007 indent to 4
1629        let mut md007_values = BTreeMap::new();
1630        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1631        config.rules.insert(
1632            "MD007".to_string(),
1633            RuleConfig {
1634                severity: None,
1635                values: md007_values,
1636            },
1637        );
1638
1639        let rule = MD073TocValidation::from_config(&config);
1640        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1641
1642        assert_eq!(rule.indent, 4, "Should read indent from MD007 config");
1643    }
1644
1645    #[test]
1646    fn test_indent_md073_overrides_md007() {
1647        use crate::config::{Config, RuleConfig};
1648        use std::collections::BTreeMap;
1649
1650        let mut config = Config::default();
1651
1652        // Set MD007 indent to 4
1653        let mut md007_values = BTreeMap::new();
1654        md007_values.insert("indent".to_string(), toml::Value::Integer(4));
1655        config.rules.insert(
1656            "MD007".to_string(),
1657            RuleConfig {
1658                severity: None,
1659                values: md007_values,
1660            },
1661        );
1662
1663        // Set MD073 indent to 3 (should override MD007)
1664        let mut md073_values = BTreeMap::new();
1665        md073_values.insert("enabled".to_string(), toml::Value::Boolean(true));
1666        md073_values.insert("indent".to_string(), toml::Value::Integer(3));
1667        config.rules.insert(
1668            "MD073".to_string(),
1669            RuleConfig {
1670                severity: None,
1671                values: md073_values,
1672            },
1673        );
1674
1675        let rule = MD073TocValidation::from_config(&config);
1676        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1677
1678        assert_eq!(rule.indent, 3, "MD073 indent should override MD007");
1679    }
1680
1681    #[test]
1682    fn test_generate_toc_with_4_space_indent() {
1683        let mut rule = create_enabled_rule();
1684        rule.indent = 4;
1685
1686        let content = r#"<!-- toc -->
1687
1688<!-- tocstop -->
1689
1690## Level 2
1691
1692### Level 3
1693
1694#### Level 4
1695
1696## Another Level 2
1697"#;
1698        let ctx = create_ctx(content);
1699        let region = rule.detect_toc_region(&ctx).unwrap();
1700        let expected = rule.build_expected_toc(&ctx, &region);
1701        let toc = rule.generate_toc(&expected);
1702
1703        // With 4-space indent:
1704        // Level 2 = 0 spaces (base level)
1705        // Level 3 = 4 spaces
1706        // Level 4 = 8 spaces
1707        assert!(toc.contains("- [Level 2](#level-2)"), "Level 2 should have no indent");
1708        assert!(
1709            toc.contains("    - [Level 3](#level-3)"),
1710            "Level 3 should have 4-space indent"
1711        );
1712        assert!(
1713            toc.contains("        - [Level 4](#level-4)"),
1714            "Level 4 should have 8-space indent"
1715        );
1716        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1717    }
1718
1719    #[test]
1720    fn test_validate_toc_with_4_space_indent() {
1721        let mut rule = create_enabled_rule();
1722        rule.indent = 4;
1723
1724        // TOC with correct 4-space indentation
1725        let content = r#"<!-- toc -->
1726- [Hello](#hello)
1727    - [Another](#another)
1728- [Heading](#heading)
1729<!-- tocstop -->
1730
1731## Hello
1732
1733### Another
1734
1735## Heading
1736"#;
1737        let ctx = create_ctx(content);
1738        let result = rule.check(&ctx).unwrap();
1739        assert!(
1740            result.is_empty(),
1741            "Should accept 4-space indent when configured: {result:?}"
1742        );
1743    }
1744
1745    #[test]
1746    fn test_validate_toc_wrong_indent_with_4_space_config() {
1747        let mut rule = create_enabled_rule();
1748        rule.indent = 4;
1749
1750        // TOC with 2-space indentation (wrong when 4-space is configured)
1751        let content = r#"<!-- toc -->
1752- [Hello](#hello)
1753  - [Another](#another)
1754- [Heading](#heading)
1755<!-- tocstop -->
1756
1757## Hello
1758
1759### Another
1760
1761## Heading
1762"#;
1763        let ctx = create_ctx(content);
1764        let result = rule.check(&ctx).unwrap();
1765        assert_eq!(result.len(), 1, "Should detect wrong indent");
1766        assert!(
1767            result[0].message.contains("Indentation mismatch"),
1768            "Should report indentation mismatch: {}",
1769            result[0].message
1770        );
1771        assert!(
1772            result[0].message.contains("expected 4 spaces"),
1773            "Should mention expected 4 spaces: {}",
1774            result[0].message
1775        );
1776    }
1777
1778    // ========== Markdown Stripping Tests ==========
1779
1780    #[test]
1781    fn test_strip_markdown_formatting_link() {
1782        let result = strip_markdown_formatting("Tool: [terminal](https://example.com)");
1783        assert_eq!(result, "Tool: terminal");
1784    }
1785
1786    #[test]
1787    fn test_strip_markdown_formatting_bold() {
1788        let result = strip_markdown_formatting("This is **bold** text");
1789        assert_eq!(result, "This is bold text");
1790
1791        let result = strip_markdown_formatting("This is __bold__ text");
1792        assert_eq!(result, "This is bold text");
1793    }
1794
1795    #[test]
1796    fn test_strip_markdown_formatting_italic() {
1797        let result = strip_markdown_formatting("This is *italic* text");
1798        assert_eq!(result, "This is italic text");
1799
1800        let result = strip_markdown_formatting("This is _italic_ text");
1801        assert_eq!(result, "This is italic text");
1802    }
1803
1804    #[test]
1805    fn test_strip_markdown_formatting_code_span() {
1806        let result = strip_markdown_formatting("Use the `format` function");
1807        assert_eq!(result, "Use the format function");
1808    }
1809
1810    #[test]
1811    fn test_strip_markdown_formatting_image() {
1812        let result = strip_markdown_formatting("See ![logo](image.png) for details");
1813        assert_eq!(result, "See logo for details");
1814    }
1815
1816    #[test]
1817    fn test_strip_markdown_formatting_reference_link() {
1818        let result = strip_markdown_formatting("See [documentation][docs] for details");
1819        assert_eq!(result, "See documentation for details");
1820    }
1821
1822    #[test]
1823    fn test_strip_markdown_formatting_combined() {
1824        // Link is stripped first, leaving bold, then bold is stripped
1825        let result = strip_markdown_formatting("Tool: [**terminal**](https://example.com)");
1826        assert_eq!(result, "Tool: terminal");
1827    }
1828
1829    #[test]
1830    fn test_toc_with_link_in_heading_matches_stripped_text() {
1831        let rule = create_enabled_rule();
1832
1833        // TOC entry text matches the stripped heading text
1834        let content = r#"# Title
1835
1836<!-- toc -->
1837
1838- [Tool: terminal](#tool-terminal)
1839
1840<!-- tocstop -->
1841
1842## Tool: [terminal](https://example.com)
1843
1844Content here.
1845"#;
1846        let ctx = create_ctx(content);
1847        let result = rule.check(&ctx).unwrap();
1848        assert!(
1849            result.is_empty(),
1850            "Stripped heading text should match TOC entry: {result:?}"
1851        );
1852    }
1853
1854    #[test]
1855    fn test_toc_with_simplified_text_still_mismatches() {
1856        let rule = create_enabled_rule();
1857
1858        // TOC entry "terminal" does NOT match stripped heading "Tool: terminal"
1859        let content = r#"# Title
1860
1861<!-- toc -->
1862
1863- [terminal](#tool-terminal)
1864
1865<!-- tocstop -->
1866
1867## Tool: [terminal](https://example.com)
1868
1869Content here.
1870"#;
1871        let ctx = create_ctx(content);
1872        let result = rule.check(&ctx).unwrap();
1873        assert_eq!(result.len(), 1, "Should report text mismatch");
1874        assert!(result[0].message.contains("Text mismatch"));
1875    }
1876
1877    #[test]
1878    fn test_fix_generates_stripped_toc_entries() {
1879        let rule = MD073TocValidation::new();
1880        let content = r#"# Title
1881
1882<!-- toc -->
1883
1884<!-- tocstop -->
1885
1886## Tool: [busybox](https://www.busybox.net/)
1887
1888Content.
1889
1890## Tool: [mount](https://en.wikipedia.org/wiki/Mount)
1891
1892More content.
1893"#;
1894        let ctx = create_ctx(content);
1895        let fixed = rule.fix(&ctx).unwrap();
1896
1897        // Generated TOC should have stripped text (links removed)
1898        assert!(
1899            fixed.contains("- [Tool: busybox](#tool-busybox)"),
1900            "TOC entry should have stripped link text"
1901        );
1902        assert!(
1903            fixed.contains("- [Tool: mount](#tool-mount)"),
1904            "TOC entry should have stripped link text"
1905        );
1906        // TOC entries should NOT contain the URL (the actual headings in the document still will)
1907        // Check only within the TOC region (between toc markers)
1908        let toc_start = fixed.find("<!-- toc -->").unwrap();
1909        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
1910        let toc_content = &fixed[toc_start..toc_end];
1911        assert!(
1912            !toc_content.contains("busybox.net"),
1913            "TOC should not contain URLs: {toc_content}"
1914        );
1915        assert!(
1916            !toc_content.contains("wikipedia.org"),
1917            "TOC should not contain URLs: {toc_content}"
1918        );
1919    }
1920
1921    #[test]
1922    fn test_fix_with_bold_in_heading() {
1923        let rule = MD073TocValidation::new();
1924        let content = r#"# Title
1925
1926<!-- toc -->
1927
1928<!-- tocstop -->
1929
1930## **Important** Section
1931
1932Content.
1933"#;
1934        let ctx = create_ctx(content);
1935        let fixed = rule.fix(&ctx).unwrap();
1936
1937        // Generated TOC preserves bold markers in display text; anchor strips them.
1938        assert!(fixed.contains("- [**Important** Section](#important-section)"));
1939    }
1940
1941    #[test]
1942    fn test_fix_with_code_in_heading() {
1943        let rule = MD073TocValidation::new();
1944        let content = r#"# Title
1945
1946<!-- toc -->
1947
1948<!-- tocstop -->
1949
1950## Using `async` Functions
1951
1952Content.
1953"#;
1954        let ctx = create_ctx(content);
1955        let fixed = rule.fix(&ctx).unwrap();
1956
1957        // Generated TOC preserves code ticks in display text; anchor strips them.
1958        assert!(fixed.contains("- [Using `async` Functions](#using-async-functions)"));
1959    }
1960
1961    // ========== Custom Anchor Tests ==========
1962
1963    #[test]
1964    fn test_custom_anchor_id_respected() {
1965        let rule = create_enabled_rule();
1966        let content = r#"# Title
1967
1968<!-- toc -->
1969
1970- [My Section](#my-custom-anchor)
1971
1972<!-- tocstop -->
1973
1974## My Section {#my-custom-anchor}
1975
1976Content here.
1977"#;
1978        let ctx = create_ctx(content);
1979        let result = rule.check(&ctx).unwrap();
1980        assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1981    }
1982
1983    #[test]
1984    fn test_custom_anchor_id_in_generated_toc() {
1985        let rule = create_enabled_rule();
1986        let content = r#"# Title
1987
1988<!-- toc -->
1989
1990<!-- tocstop -->
1991
1992## First Section {#custom-first}
1993
1994Content.
1995
1996## Second Section {#another-custom}
1997
1998More content.
1999"#;
2000        let ctx = create_ctx(content);
2001        let fixed = rule.fix(&ctx).unwrap();
2002        assert!(fixed.contains("- [First Section](#custom-first)"));
2003        assert!(fixed.contains("- [Second Section](#another-custom)"));
2004    }
2005
2006    #[test]
2007    fn test_mixed_custom_and_generated_anchors() {
2008        let rule = create_enabled_rule();
2009        let content = r#"# Title
2010
2011<!-- toc -->
2012
2013- [Custom Section](#my-id)
2014- [Normal Section](#normal-section)
2015
2016<!-- tocstop -->
2017
2018## Custom Section {#my-id}
2019
2020Content.
2021
2022## Normal Section
2023
2024More content.
2025"#;
2026        let ctx = create_ctx(content);
2027        let result = rule.check(&ctx).unwrap();
2028        assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
2029    }
2030
2031    // ========== Anchor Generation Tests ==========
2032
2033    #[test]
2034    fn test_github_anchor_style() {
2035        let rule = create_enabled_rule();
2036
2037        let content = r#"<!-- toc -->
2038
2039<!-- tocstop -->
2040
2041## Test_With_Underscores
2042
2043Content.
2044"#;
2045        let ctx = create_ctx(content);
2046        let region = rule.detect_toc_region(&ctx).unwrap();
2047        let expected = rule.build_expected_toc(&ctx, &region);
2048
2049        // GitHub-style anchors preserve underscores
2050        assert_eq!(expected[0].anchor, "test_with_underscores");
2051    }
2052
2053    // ========== Stress Tests ==========
2054
2055    #[test]
2056    fn test_stress_many_headings() {
2057        let rule = create_enabled_rule();
2058
2059        // Generate a document with 150 headings
2060        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2061
2062        for i in 1..=150 {
2063            content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
2064        }
2065
2066        let ctx = create_ctx(&content);
2067
2068        // Should not panic or timeout
2069        let result = rule.check(&ctx).unwrap();
2070
2071        // Should report missing entries for all 150 headings
2072        assert_eq!(result.len(), 1, "Should report single warning for TOC");
2073        assert!(result[0].message.contains("Missing entry"));
2074
2075        // Fix should generate TOC with 150 entries
2076        let fixed = rule.fix(&ctx).unwrap();
2077        assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
2078        assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
2079        assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
2080    }
2081
2082    #[test]
2083    fn test_stress_deeply_nested() {
2084        let rule = create_enabled_rule();
2085        let content = r#"# Title
2086
2087<!-- toc -->
2088
2089<!-- tocstop -->
2090
2091## Level 2 A
2092
2093### Level 3 A
2094
2095#### Level 4 A
2096
2097## Level 2 B
2098
2099### Level 3 B
2100
2101#### Level 4 B
2102
2103## Level 2 C
2104
2105### Level 3 C
2106
2107#### Level 4 C
2108
2109## Level 2 D
2110
2111### Level 3 D
2112
2113#### Level 4 D
2114"#;
2115        let ctx = create_ctx(content);
2116        let fixed = rule.fix(&ctx).unwrap();
2117
2118        // Check nested indentation is correct
2119        assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
2120        assert!(fixed.contains("  - [Level 3 A](#level-3-a)"));
2121        assert!(fixed.contains("    - [Level 4 A](#level-4-a)"));
2122        assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
2123        assert!(fixed.contains("  - [Level 3 D](#level-3-d)"));
2124        assert!(fixed.contains("    - [Level 4 D](#level-4-d)"));
2125    }
2126
2127    // ==================== Duplicate TOC anchors ====================
2128
2129    #[test]
2130    fn test_duplicate_toc_anchors_produce_correct_diagnostics() {
2131        let rule = create_enabled_rule();
2132        // Document has headings "Example", "Another", "Example" which produce anchors:
2133        // "example", "another", "example-1"
2134        // TOC incorrectly uses #example twice instead of #example and #example-1
2135        let content = r#"# Document
2136
2137<!-- toc -->
2138
2139- [Example](#example)
2140- [Another](#another)
2141- [Example](#example)
2142
2143<!-- tocstop -->
2144
2145## Example
2146First.
2147
2148## Another
2149Middle.
2150
2151## Example
2152Second.
2153"#;
2154        let ctx = create_ctx(content);
2155        let result = rule.check(&ctx).unwrap();
2156
2157        // The TOC has #example twice but expected has #example and #example-1.
2158        // Should report that #example-1 is missing from the TOC.
2159        assert!(!result.is_empty(), "Should detect mismatch with duplicate TOC anchors");
2160        assert!(
2161            result[0].message.contains("Missing entry") || result[0].message.contains("Stale entry"),
2162            "Should report missing or stale entries for duplicate anchors. Got: {}",
2163            result[0].message
2164        );
2165    }
2166
2167    // ==================== Multi-backtick code spans ====================
2168
2169    #[test]
2170    fn test_strip_double_backtick_code_span() {
2171        // Double-backtick code spans should be stripped
2172        let result = strip_markdown_formatting("Using ``code with ` backtick``");
2173        assert_eq!(
2174            result, "Using code with ` backtick",
2175            "Should strip double-backtick code spans"
2176        );
2177    }
2178
2179    #[test]
2180    fn test_strip_triple_backtick_code_span() {
2181        // Triple-backtick code spans should be stripped
2182        let result = strip_markdown_formatting("Using ```code with `` backticks```");
2183        assert_eq!(
2184            result, "Using code with `` backticks",
2185            "Should strip triple-backtick code spans"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_toc_with_double_backtick_heading() {
2191        let rule = create_enabled_rule();
2192        // Use fix() to generate the correct TOC (including anchor), then check()
2193        // should produce no warnings on the fixed output.
2194        let content = r#"# Title
2195
2196<!-- toc -->
2197
2198<!-- tocstop -->
2199
2200## Using ``code with ` backtick``
2201
2202Content here.
2203"#;
2204        let ctx = create_ctx(content);
2205        // The heading uses double-backtick code span: ``code with ` backtick``
2206        // TOC display text preserves the code span; anchor is derived from raw text.
2207        let fixed = rule.fix(&ctx).unwrap();
2208
2209        // Verify that the generated TOC entry preserves the double-backtick code span
2210        // in the display text.
2211        let toc_start = fixed.find("<!-- toc -->").unwrap();
2212        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2213        let toc_content = &fixed[toc_start..toc_end];
2214        assert!(
2215            toc_content.contains("``code with ` backtick``"),
2216            "Fix should preserve double-backtick code span in TOC display text. Got: {toc_content}"
2217        );
2218
2219        // After fix, check() must produce no warnings (idempotency check)
2220        let ctx2 = create_ctx(&fixed);
2221        let result = rule.check(&ctx2).unwrap();
2222        assert!(
2223            result.is_empty(),
2224            "check() should not warn on fixed output. Warnings: {result:?}"
2225        );
2226    }
2227
2228    #[test]
2229    fn test_stress_many_duplicates() {
2230        let rule = create_enabled_rule();
2231
2232        // Generate 50 headings with the same text
2233        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
2234        for _ in 0..50 {
2235            content.push_str("## FAQ\n\nContent.\n\n");
2236        }
2237
2238        let ctx = create_ctx(&content);
2239        let region = rule.detect_toc_region(&ctx).unwrap();
2240        let expected = rule.build_expected_toc(&ctx, &region);
2241
2242        // Should generate unique anchors for all 50
2243        assert_eq!(expected.len(), 50);
2244        assert_eq!(expected[0].anchor, "faq");
2245        assert_eq!(expected[1].anchor, "faq-1");
2246        assert_eq!(expected[49].anchor, "faq-49");
2247    }
2248
2249    /// Core invariant: for every warning with a Fix, fix() must produce
2250    /// output consistent with applying that fix directly.
2251    #[test]
2252    fn test_roundtrip_check_and_fix_alignment() {
2253        let rule = create_enabled_rule();
2254
2255        let inputs = [
2256            // Stale entry
2257            "# Title\n\n<!-- toc -->\n- [Old Section](#old-section)\n<!-- tocstop -->\n\n## New Section\n",
2258            // Missing entry
2259            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2260            // Text mismatch
2261            "# Title\n\n<!-- toc -->\n- [Wrong Text](#real-section)\n<!-- tocstop -->\n\n## Real Section\n",
2262            // Already correct (no warnings, no change)
2263            "# Title\n\n<!-- toc -->\n- [One](#one)\n- [Two](#two)\n<!-- tocstop -->\n\n## One\n\n## Two\n",
2264        ];
2265
2266        for input in &inputs {
2267            let ctx = create_ctx(input);
2268            let fixed = rule.fix(&ctx).unwrap();
2269
2270            // Idempotency: fix(fix(x)) == fix(x)
2271            let ctx2 = create_ctx(&fixed);
2272            let fixed_twice = rule.fix(&ctx2).unwrap();
2273            assert_eq!(
2274                fixed, fixed_twice,
2275                "fix() is not idempotent for input: {input:?}\nfirst:  {fixed:?}\nsecond: {fixed_twice:?}"
2276            );
2277
2278            // After fix, check() should produce no warnings
2279            let warnings_after = rule.check(&ctx2).unwrap();
2280            assert!(
2281                warnings_after.is_empty(),
2282                "check() should return no warnings after fix() for input: {input:?}\nfixed: {fixed:?}\nwarnings: {warnings_after:?}"
2283            );
2284        }
2285    }
2286
2287    /// If a TOC has no mismatches, check() emits no warnings and fix()
2288    /// returns content unchanged.
2289    #[test]
2290    fn test_no_mismatch_preserves_content() {
2291        let rule = create_enabled_rule();
2292
2293        let content = "# Title\n\n<!-- toc -->\n- [First Section](#first-section)\n- [Second Section](#second-section)\n<!-- tocstop -->\n\n## First Section\n\ntext\n\n## Second Section\n\ntext\n";
2294        let ctx = create_ctx(content);
2295
2296        let warnings = rule.check(&ctx).unwrap();
2297        assert!(warnings.is_empty(), "No mismatches should emit no warnings");
2298
2299        let fixed = rule.fix(&ctx).unwrap();
2300        assert_eq!(fixed, content, "Content should be unchanged when TOC matches headings");
2301    }
2302
2303    /// Inline-disabled TOC should not be modified by fix().
2304    #[test]
2305    fn test_inline_disable_preserves_toc() {
2306        let rule = create_enabled_rule();
2307
2308        // TOC with a stale entry, but MD073 disabled for the TOC region
2309        let content = "# Title\n\n<!-- rumdl-disable MD073 -->\n<!-- toc -->\n- [Stale](#stale)\n<!-- tocstop -->\n<!-- rumdl-enable MD073 -->\n\n## Real\n";
2310        let ctx = create_ctx(content);
2311
2312        let fixed = rule.fix(&ctx).unwrap();
2313        assert_eq!(fixed, content, "TOC in a disabled region should be preserved exactly");
2314    }
2315
2316    // ========== Inline Formatting Preservation Tests (#634) ==========
2317
2318    /// Backticks in a heading must be preserved in the TOC display text.
2319    /// The anchor is generated from the raw heading text (which includes backticks)
2320    /// and must still use the stripped form.
2321    #[test]
2322    fn test_fix_code_ticks_preserved_in_toc_display_text() {
2323        let rule = MD073TocValidation::new();
2324        let content = r#"# Title
2325
2326<!-- toc -->
2327
2328<!-- tocstop -->
2329
2330### `my header`
2331
2332Content.
2333"#;
2334        let ctx = create_ctx(content);
2335        let fixed = rule.fix(&ctx).unwrap();
2336
2337        assert!(
2338            fixed.contains("- [`my header`](#my-header)"),
2339            "Code ticks must be preserved in TOC display text. Got: {fixed}"
2340        );
2341    }
2342
2343    /// A correct user-written TOC entry with code ticks must not be re-flagged.
2344    #[test]
2345    fn test_validate_toc_with_code_ticks_is_valid() {
2346        let rule = create_enabled_rule();
2347        let content = r#"# Title
2348
2349<!-- toc -->
2350
2351- [`my header`](#my-header)
2352
2353<!-- tocstop -->
2354
2355## `my header`
2356
2357Content.
2358"#;
2359        let ctx = create_ctx(content);
2360        let result = rule.check(&ctx).unwrap();
2361        assert!(
2362            result.is_empty(),
2363            "A TOC entry with preserved code ticks should be accepted as valid: {result:?}"
2364        );
2365    }
2366
2367    /// A heading with bold/italic preserves emphasis markers in the TOC display text;
2368    /// the anchor is generated from the raw (formatted) heading text and still uses
2369    /// the stripped form.
2370    #[test]
2371    fn test_fix_emphasis_preserved_in_toc_display_text() {
2372        let rule = MD073TocValidation::new();
2373        let content = r#"# Title
2374
2375<!-- toc -->
2376
2377<!-- tocstop -->
2378
2379## **bold** and *italic*
2380
2381Content.
2382"#;
2383        let ctx = create_ctx(content);
2384        let fixed = rule.fix(&ctx).unwrap();
2385
2386        assert!(
2387            fixed.contains("- [**bold** and *italic*](#bold-and-italic)"),
2388            "Emphasis markers must be preserved in TOC display text. Got: {fixed}"
2389        );
2390    }
2391
2392    /// A heading containing a link must have the link stripped from the TOC display
2393    /// text (nested links are invalid in Markdown).
2394    #[test]
2395    fn test_fix_link_in_heading_is_stripped() {
2396        let rule = MD073TocValidation::new();
2397        let content = r#"# Title
2398
2399<!-- toc -->
2400
2401<!-- tocstop -->
2402
2403## See [docs](http://example.com) for details
2404
2405Content.
2406"#;
2407        let ctx = create_ctx(content);
2408        let fixed = rule.fix(&ctx).unwrap();
2409
2410        assert!(
2411            fixed.contains("- [See docs for details](#see-docs-for-details)"),
2412            "Link must be stripped from TOC display text. Got: {fixed}"
2413        );
2414        // Ensure no URL leaks into TOC entry
2415        let toc_start = fixed.find("<!-- toc -->").unwrap();
2416        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2417        let toc_content = &fixed[toc_start..toc_end];
2418        assert!(
2419            !toc_content.contains("http://example.com"),
2420            "TOC should not contain link URL: {toc_content}"
2421        );
2422    }
2423
2424    /// An image in a heading must still be stripped from the TOC display text.
2425    #[test]
2426    fn test_fix_image_in_heading_is_stripped() {
2427        let rule = MD073TocValidation::new();
2428        let content = r#"# Title
2429
2430<!-- toc -->
2431
2432<!-- tocstop -->
2433
2434## Section ![icon](icon.png) Title
2435
2436Content.
2437"#;
2438        let ctx = create_ctx(content);
2439        let fixed = rule.fix(&ctx).unwrap();
2440
2441        assert!(
2442            fixed.contains("- [Section icon Title](#section-icon-title)"),
2443            "Image must be stripped from TOC display text. Got: {fixed}"
2444        );
2445    }
2446
2447    /// Running fix() twice on a document with inline-formatted headings must
2448    /// produce stable output (idempotency).
2449    #[test]
2450    fn test_fix_idempotent_with_inline_formatting() {
2451        let rule = MD073TocValidation::new();
2452        let content = r#"# Title
2453
2454<!-- toc -->
2455
2456<!-- tocstop -->
2457
2458## `code` heading
2459
2460### **bold** heading
2461
2462## See [link](http://x.com)
2463
2464"#;
2465        let ctx = create_ctx(content);
2466        let fixed1 = rule.fix(&ctx).unwrap();
2467        let ctx2 = create_ctx(&fixed1);
2468        let fixed2 = rule.fix(&ctx2).unwrap();
2469
2470        assert_eq!(fixed1, fixed2, "fix() must be idempotent for inline-formatted headings");
2471
2472        // After fix, check() must produce no warnings
2473        let warnings = rule.check(&ctx2).unwrap();
2474        assert!(
2475            warnings.is_empty(),
2476            "check() must not warn after fix() for inline-formatted headings: {warnings:?}"
2477        );
2478    }
2479
2480    /// Link-like syntax inside a code span must not be stripped, because it is
2481    /// literal content of the code span and not a real Markdown link.
2482    #[test]
2483    fn test_link_inside_code_span_preserved_in_toc() {
2484        let rule = MD073TocValidation::new();
2485        let content = r#"# Title
2486
2487<!-- toc -->
2488
2489<!-- tocstop -->
2490
2491## Use `[foo](bar)` syntax
2492
2493Content.
2494"#;
2495        let ctx = create_ctx(content);
2496        let fixed = rule.fix(&ctx).unwrap();
2497
2498        // The code span `[foo](bar)` must survive intact in the TOC display text.
2499        // The anchor is generated from the raw heading text by the GitHub algorithm,
2500        // which strips backtick, bracket, and paren characters. Verify only the
2501        // display-text preservation, not the exact anchor (which depends on the anchor
2502        // generation algorithm's treatment of non-alphanumeric chars in code spans).
2503        let toc_start = fixed.find("<!-- toc -->").unwrap();
2504        let toc_end = fixed.find("<!-- tocstop -->").unwrap();
2505        let toc_content = &fixed[toc_start..toc_end];
2506        assert!(
2507            toc_content.contains("Use `[foo](bar)` syntax"),
2508            "Link-like text inside code span must be preserved in TOC display text. Got: {toc_content}"
2509        );
2510        // Also ensure the real link stripping (outside code spans) still works
2511        assert!(
2512            !toc_content.contains("http://"),
2513            "Real links (outside code spans) should be stripped: {toc_content}"
2514        );
2515    }
2516
2517    // ========== HTML anchor targets ==========
2518
2519    /// The TOC region after `fix`, without the markers and surrounding blank lines.
2520    fn generated_toc(content: &str) -> String {
2521        let rule = create_enabled_rule();
2522        let ctx = create_ctx(content);
2523        let fixed = rule.fix(&ctx).unwrap();
2524        let start = fixed.find("<!-- toc -->").unwrap() + "<!-- toc -->".len();
2525        let end = fixed.find("<!-- tocstop -->").unwrap();
2526        fixed[start..end].trim().to_string()
2527    }
2528
2529    fn check_toc(content: &str) -> Vec<LintWarning> {
2530        let rule = create_enabled_rule();
2531        let ctx = create_ctx(content);
2532        rule.check(&ctx).unwrap()
2533    }
2534
2535    #[test]
2536    fn test_toc_may_target_the_generated_slug_of_a_heading_with_an_html_anchor() {
2537        // GitHub gives the heading both `#cheat-sheets` (generated) and `#cheatsheets`
2538        // (the element's own id), so a TOC written against either reaches it.
2539        for anchor in ["cheat-sheets", "cheatsheets"] {
2540            let content = format!(
2541                "# Title\n\n<!-- toc -->\n\n- [Cheat Sheets](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n\nContent.\n"
2542            );
2543            let result = check_toc(&content);
2544            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2545        }
2546    }
2547
2548    #[test]
2549    fn test_every_anchor_element_on_a_heading_is_a_valid_target() {
2550        for anchor in ["old", "older", "foo"] {
2551            let content = format!(
2552                "# Title\n\n<!-- toc -->\n\n- [Foo](#{anchor})\n\n<!-- tocstop -->\n\n## <a name=\"old\"></a><a id=\"older\"></a>Foo\n"
2553            );
2554            let result = check_toc(&content);
2555            assert!(result.is_empty(), "#{anchor} reaches the heading, got {result:?}");
2556        }
2557    }
2558
2559    #[test]
2560    fn test_generated_toc_prefers_the_explicit_html_anchor() {
2561        let toc =
2562            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <a name=\"cheatsheets\"></a>Cheat Sheets\n");
2563        assert_eq!(toc, "- [Cheat Sheets](#cheatsheets)");
2564    }
2565
2566    #[test]
2567    fn test_regeneration_keeps_an_existing_anchor_that_reaches_its_heading() {
2568        // The unrelated missing entry forces a rewrite; the entry already reaching its
2569        // heading is not switched to the other valid spelling on the way.
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";
2571        assert_eq!(
2572            generated_toc(content),
2573            "- [Cheat Sheets](#cheat-sheets)\n- [Other](#other)"
2574        );
2575    }
2576
2577    #[test]
2578    fn test_an_attribute_merely_ending_in_id_or_name_is_not_an_anchor() {
2579        let toc = generated_toc(
2580            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\" data-name=\"pixel\"></a>\n",
2581        );
2582        assert_eq!(toc, "- [Foo](#foo)");
2583
2584        let result = check_toc(
2585            "# Title\n\n<!-- toc -->\n\n- [Foo](#tracking)\n\n<!-- tocstop -->\n\n## Foo<a data-id=\"tracking\"></a>\n",
2586        );
2587        assert_eq!(result.len(), 1, "{result:?}");
2588        assert!(
2589            result[0].message.contains("Stale entry: 'Foo'"),
2590            "{}",
2591            result[0].message
2592        );
2593        assert!(
2594            result[0].message.contains("Missing entry: 'Foo'"),
2595            "{}",
2596            result[0].message
2597        );
2598    }
2599
2600    #[test]
2601    fn test_an_empty_id_is_not_an_anchor() {
2602        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"\"></a>\n");
2603        assert_eq!(toc, "- [Foo](#foo)");
2604    }
2605
2606    #[test]
2607    fn test_an_explicit_anchor_still_consumes_the_generated_slug() {
2608        // GitHub numbers duplicate generated slugs whether or not the first heading also
2609        // carries its own id, so the second `Same` is `same-1`, never `same`.
2610        let toc =
2611            generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Same<a id=\"stable\"></a>\n\n## Same\n");
2612        assert_eq!(toc, "- [Same](#stable)\n- [Same](#same-1)");
2613    }
2614
2615    #[test]
2616    fn test_anchor_markup_inside_a_code_span_is_heading_text() {
2617        let heading = "Showing `<a id=\"literal\"></a>` syntax";
2618        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2619        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2620        assert_ne!(slug, "literal");
2621        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2622    }
2623
2624    #[test]
2625    fn test_anchor_markup_inside_an_html_comment_is_not_a_target() {
2626        let heading = "Foo <!-- <a id=\"hidden\"></a> -->";
2627        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2628        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2629        assert_ne!(slug, "hidden");
2630        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2631    }
2632
2633    #[test]
2634    fn test_an_explicit_anchor_is_written_as_a_valid_link_destination() {
2635        // A bare link destination cannot hold `)` or a space, so the anchor is
2636        // percent-encoded on the way out, and the encoded entry reaches the heading
2637        // again on the next check, so the generated TOC is stable.
2638        let headings = "## Alpha<a id=\"foo)bar\"></a>\n\n## Beta<a id=\"has space\"></a>\n";
2639        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{headings}"));
2640        assert_eq!(toc, "- [Alpha](#foo%29bar)\n- [Beta](#has%20space)");
2641
2642        let result = check_toc(&format!(
2643            "# Title\n\n<!-- toc -->\n\n{toc}\n\n<!-- tocstop -->\n\n{headings}"
2644        ));
2645        assert!(result.is_empty(), "{result:?}");
2646    }
2647
2648    #[test]
2649    fn test_regeneration_does_not_encode_an_encoded_entry_twice() {
2650        // The entry reaching `foo)bar` is kept as written; the stale sibling forces
2651        // the rewrite.
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";
2653        assert_eq!(generated_toc(content), "- [Alpha](#foo%29bar)");
2654    }
2655
2656    #[test]
2657    fn test_a_backslash_escaped_anchor_element_defines_no_target() {
2658        // `\<a ...>` renders as literal text, so `#example` reaches nothing.
2659        let heading = "Show \\<a id=\"example\"></a> syntax";
2660        let result = check_toc(&format!(
2661            "# Title\n\n<!-- toc -->\n\n- [{heading}](#example)\n\n<!-- tocstop -->\n\n## {heading}\n"
2662        ));
2663        assert_eq!(result.len(), 1, "{result:?}");
2664        assert!(result[0].message.contains("Stale entry"), "{}", result[0].message);
2665
2666        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2667        let slug = AnchorStyle::GitHub.generate_fragment(heading);
2668        assert_ne!(slug, "example");
2669        assert_eq!(toc, format!("- [{heading}](#{slug})"));
2670    }
2671
2672    #[test]
2673    fn test_a_backslash_escaped_marker_is_text() {
2674        // `\<!-- toc -->` renders as the literal text `<!-- toc -->`, so the pair
2675        // below delimits no TOC region and the prose between them is left alone.
2676        let content = "# Title\n\nWrite these lines:\n\n\\<!-- toc -->\n\nProse that must survive.\n\n\\<!-- tocstop -->\n\n## Alpha\n";
2677        let result = check_toc(content);
2678        assert!(result.is_empty(), "{result:?}");
2679        assert_eq!(create_enabled_rule().fix(&create_ctx(content)).unwrap(), content);
2680
2681        // An even run of backslashes leaves the `<` itself unescaped, so the comment
2682        // is rendered and the marker is real.
2683        let toc = generated_toc("# Title\n\n\\\\<!-- toc -->\n<!-- tocstop -->\n\n## Alpha\n");
2684        assert_eq!(toc, "- [Alpha](#alpha)");
2685    }
2686
2687    #[test]
2688    fn test_the_whitespace_beside_an_anchor_element_stays_in_the_heading_text() {
2689        // `Foo<a id="alias"></a> Bar` renders as "Foo Bar", so an entry written
2690        // that way is up to date and a regenerated entry reads the same.
2691        let content =
2692            "# Title\n\n<!-- toc -->\n- [Foo Bar](#foo-bar)\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n";
2693        let warnings = check_toc(content);
2694        assert!(warnings.is_empty(), "{warnings:?}");
2695
2696        let toc = generated_toc("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Foo<a id=\"alias\"></a> Bar\n");
2697        assert_eq!(toc, "- [Foo Bar](#alias)");
2698    }
2699
2700    #[test]
2701    fn test_the_whitespace_an_anchor_element_leaves_at_either_end_is_part_of_the_slug() {
2702        // `## Alpha <a id="x"></a>` renders as "Alpha", but GitHub slugs the text
2703        // content with the space the element leaves, so the heading answers to
2704        // `#alpha-` and `#x` and not to `#alpha`. A leading element likewise
2705        // leaves `#-beta`.
2706        let document = "# Title\n\n<!-- toc -->\n- [Alpha](#alpha-)\n- [Beta](#-beta)\n<!-- tocstop -->\n\n\
2707                        ## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n";
2708        let warnings = check_toc(document);
2709        assert!(warnings.is_empty(), "{warnings:?}");
2710
2711        let trimmed = document.replace("#alpha-", "#alpha").replace("#-beta", "#beta");
2712        assert_eq!(
2713            check_toc(&trimmed).len(),
2714            1,
2715            "a TOC written against trimmed slugs is stale"
2716        );
2717
2718        let toc = generated_toc(
2719            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## Alpha <a id=\"x\"></a>\n\n## <a id=\"y\"></a> Beta\n",
2720        );
2721        assert_eq!(toc, "- [Alpha](#x)\n- [Beta](#y)");
2722    }
2723
2724    #[test]
2725    fn test_a_heading_whose_text_slugs_to_nothing_gets_no_toc_entry() {
2726        // An image's description becomes its alt text, in which markup is
2727        // escaped, so `<a id="img">` inside it is no element and no target, and
2728        // the heading's text content is empty: GitHub gives it no anchor at all.
2729        // A heading no fragment can reach gets no entry, so the TOC converges
2730        // instead of regenerating an `[](#)` entry on every run.
2731        let body = "## ![<a id=\"img\"></a>](img.png)\n\n## ![](img.png)\n\n## Plain\n";
2732        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n{body}"));
2733        assert_eq!(toc, "- [Plain](#plain)");
2734
2735        let warnings = check_toc(&format!(
2736            "# Title\n\n<!-- toc -->\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2737        ));
2738        assert!(warnings.is_empty(), "{warnings:?}");
2739
2740        let warnings = check_toc(&format!(
2741            "# Title\n\n<!-- toc -->\n- [img](#img)\n- [Plain](#plain)\n<!-- tocstop -->\n\n{body}"
2742        ));
2743        assert_eq!(
2744            warnings.len(),
2745            1,
2746            "an entry for the image's anchor is stale: {warnings:?}"
2747        );
2748    }
2749
2750    #[test]
2751    fn test_an_anchor_inside_another_tags_attribute_value_is_heading_text() {
2752        // The `<a>` sits in the span's `title` value, so the browser makes no
2753        // element from it: the heading answers to `#foo` and keeps its bytes.
2754        let toc = generated_toc(
2755            "# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## <span title='<a id=\"fake\"></a>'>Foo</span>\n",
2756        );
2757        assert_eq!(toc, "- [<span title='<a id=\"fake\"></a>'>Foo</span>](#foo)");
2758    }
2759
2760    #[test]
2761    fn test_a_heading_with_a_custom_id_keeps_its_html_anchor_as_a_target() {
2762        // `{#new}` replaces the generated slug, but the element's own `name` is still
2763        // an anchor in the rendered page, so a TOC entry may use either of the two.
2764        let heading = "<a name=\"old\"></a>My Section {#new}";
2765        let result = check_toc(&format!(
2766            "# Title\n\n<!-- toc -->\n\n- [My Section](#old)\n\n<!-- tocstop -->\n\n## {heading}\n"
2767        ));
2768        assert!(result.is_empty(), "{result:?}");
2769
2770        let toc = generated_toc(&format!("# Title\n\n<!-- toc -->\n<!-- tocstop -->\n\n## {heading}\n"));
2771        assert_eq!(toc, "- [My Section](#new)");
2772
2773        let result = check_toc(&format!(
2774            "# Title\n\n<!-- toc -->\n\n- [My Section](#my-section)\n\n<!-- tocstop -->\n\n## {heading}\n"
2775        ));
2776        assert_eq!(result.len(), 1, "the generated slug is replaced, got {result:?}");
2777    }
2778}