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