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 regex::Regex;
9use std::collections::HashMap;
10use std::sync::LazyLock;
11
12/// Regex for TOC start marker: `<!-- toc -->` with optional whitespace variations
13static TOC_START_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*toc\s*-->").unwrap());
14
15/// Regex for TOC stop marker: `<!-- tocstop -->` or `<!-- /toc -->`
16static TOC_STOP_MARKER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(?i)<!--\s*(?:tocstop|/toc)\s*-->").unwrap());
17
18/// Regex for extracting TOC entries: `- [text](#anchor)` or `* [text](#anchor)`
19/// with optional leading whitespace for nested items
20/// Handles nested brackets like `[`check [PATHS...]`](#check-paths)`
21static TOC_ENTRY_PATTERN: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"^(\s*)[-*]\s+\[([^\[\]]*(?:\[[^\[\]]*\][^\[\]]*)*)\]\(#([^)]+)\)").unwrap());
23
24/// Detection method for TOC regions
25#[derive(Debug, Clone, Copy, PartialEq, Default)]
26pub enum TocDetection {
27    /// Only detect using `<!-- toc -->...<!-- tocstop -->` markers
28    #[default]
29    Markers,
30    /// Only detect using heading like "## Table of Contents"
31    Heading,
32    /// Try markers first, fall back to heading detection
33    Both,
34}
35
36/// Represents a detected TOC region in the document
37#[derive(Debug, Clone)]
38struct TocRegion {
39    /// 1-indexed start line of the TOC content (after the marker/heading)
40    start_line: usize,
41    /// 1-indexed end line of the TOC content (before the stop marker)
42    end_line: usize,
43    /// Byte offset where TOC content starts
44    content_start: usize,
45    /// Byte offset where TOC content ends
46    content_end: usize,
47}
48
49/// A parsed TOC entry from the existing TOC
50#[derive(Debug, Clone)]
51struct TocEntry {
52    /// Display text of the link
53    text: String,
54    /// Anchor/fragment (without #)
55    anchor: String,
56}
57
58/// An expected TOC entry generated from document headings
59#[derive(Debug, Clone)]
60struct ExpectedTocEntry {
61    /// 1-indexed line number of the heading
62    heading_line: usize,
63    /// Heading level (1-6)
64    level: u8,
65    /// Heading text (for display)
66    text: String,
67    /// Generated anchor
68    anchor: String,
69}
70
71/// Types of mismatches between actual and expected TOC
72#[derive(Debug)]
73enum TocMismatch {
74    /// Entry exists in TOC but heading doesn't exist
75    StaleEntry { entry: TocEntry },
76    /// Heading exists but no TOC entry for it
77    MissingEntry { expected: ExpectedTocEntry },
78    /// TOC entry text doesn't match heading text
79    TextMismatch {
80        entry: TocEntry,
81        expected: ExpectedTocEntry,
82    },
83    /// TOC entries are in wrong order
84    OrderMismatch { entry: TocEntry, expected_position: usize },
85}
86
87/// MD073: Table of Contents Validation
88///
89/// This rule validates that TOC sections match the actual document headings.
90/// It can detect TOC regions via markers (`<!-- toc -->...<!-- tocstop -->`)
91/// or by heading patterns.
92///
93/// ## Configuration
94///
95/// ```toml
96/// [MD073]
97/// # Detection method: "markers", "heading", or "both"
98/// detection = "both"
99/// # Minimum heading level to include (default: 2)
100/// min-level = 2
101/// # Maximum heading level to include (default: 4)
102/// max-level = 4
103/// # Whether TOC order must match document order (default: true)
104/// enforce-order = true
105/// # Whether to use nested indentation (default: true)
106/// nested = true
107/// # Anchor generation style (default: "github")
108/// anchor-style = "github"
109/// # Headings that indicate a TOC section
110/// toc-headings = ["Table of Contents", "Contents", "TOC"]
111/// ```
112#[derive(Clone)]
113pub struct MD073TocValidation {
114    /// How to detect TOC regions
115    detection: TocDetection,
116    /// Minimum heading level to include
117    min_level: u8,
118    /// Maximum heading level to include
119    max_level: u8,
120    /// Whether to enforce order matching
121    enforce_order: bool,
122    /// Whether to nest entries based on heading level
123    nested: bool,
124    /// Anchor generation style
125    anchor_style: AnchorStyle,
126    /// Heading patterns that indicate TOC sections
127    toc_headings: Vec<String>,
128}
129
130impl Default for MD073TocValidation {
131    fn default() -> Self {
132        Self {
133            detection: TocDetection::Both,
134            min_level: 2,
135            max_level: 4,
136            enforce_order: true,
137            nested: true,
138            anchor_style: AnchorStyle::GitHub,
139            toc_headings: vec![
140                "Table of Contents".to_string(),
141                "Contents".to_string(),
142                "TOC".to_string(),
143            ],
144        }
145    }
146}
147
148impl std::fmt::Debug for MD073TocValidation {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("MD073TocValidation")
151            .field("detection", &self.detection)
152            .field("min_level", &self.min_level)
153            .field("max_level", &self.max_level)
154            .field("enforce_order", &self.enforce_order)
155            .field("nested", &self.nested)
156            .field("toc_headings", &self.toc_headings)
157            .finish()
158    }
159}
160
161impl MD073TocValidation {
162    /// Create a new rule with default settings
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Detect TOC region using markers
168    fn detect_by_markers(&self, ctx: &LintContext) -> Option<TocRegion> {
169        let mut start_line = None;
170        let mut start_byte = None;
171
172        for (idx, line_info) in ctx.lines.iter().enumerate() {
173            let line_num = idx + 1;
174            let content = line_info.content(ctx.content);
175
176            // Skip if in code block or front matter
177            if line_info.in_code_block || line_info.in_front_matter {
178                continue;
179            }
180
181            // Look for start marker or stop marker
182            if let (Some(s_line), Some(s_byte)) = (start_line, start_byte) {
183                // We have a start, now look for stop marker
184                if TOC_STOP_MARKER.is_match(content) {
185                    let end_line = line_num - 1;
186                    let content_end = line_info.byte_offset;
187
188                    // Handle case where there's no content between markers
189                    if end_line < s_line {
190                        return Some(TocRegion {
191                            start_line: s_line,
192                            end_line: s_line,
193                            content_start: s_byte,
194                            content_end: s_byte,
195                        });
196                    }
197
198                    return Some(TocRegion {
199                        start_line: s_line,
200                        end_line,
201                        content_start: s_byte,
202                        content_end,
203                    });
204                }
205            } else if TOC_START_MARKER.is_match(content) {
206                // TOC content starts on the next line
207                if idx + 1 < ctx.lines.len() {
208                    start_line = Some(line_num + 1);
209                    start_byte = Some(ctx.lines[idx + 1].byte_offset);
210                }
211            }
212        }
213
214        None
215    }
216
217    /// Detect TOC region by heading pattern
218    fn detect_by_heading(&self, ctx: &LintContext) -> Option<TocRegion> {
219        let mut toc_heading_line = None;
220        let mut content_start_line = None;
221        let mut content_start_byte = None;
222        let mut blank_streak = 0usize;
223
224        for (idx, line_info) in ctx.lines.iter().enumerate() {
225            let line_num = idx + 1;
226
227            // Skip if in code block or front matter
228            if line_info.in_code_block || line_info.in_front_matter {
229                continue;
230            }
231
232            // Look for TOC heading
233            if toc_heading_line.is_none() {
234                if let Some(heading) = &line_info.heading {
235                    // Check if heading text matches any TOC heading pattern
236                    let heading_text = heading.text.trim();
237                    if self.toc_headings.iter().any(|h| h.eq_ignore_ascii_case(heading_text)) {
238                        toc_heading_line = Some(line_num);
239                        // Content starts on the next line
240                        if idx + 1 < ctx.lines.len() {
241                            content_start_line = Some(line_num + 1);
242                            content_start_byte = Some(ctx.lines[idx + 1].byte_offset);
243                        }
244                    }
245                }
246            } else if content_start_line.is_some() {
247                // We found the TOC heading, now find where the TOC ends
248                // TOC ends at:
249                // 1. Next heading
250                // 2. Two consecutive blank lines
251                // 3. End of document
252
253                // Check for next heading
254                if line_info.heading.is_some() {
255                    let end_line = line_num - 1;
256                    let content_end = line_info.byte_offset;
257
258                    // Skip backwards over trailing blank lines
259                    let mut actual_end = end_line;
260                    while actual_end >= content_start_line.unwrap() {
261                        let check_idx = actual_end - 1;
262                        if check_idx < ctx.lines.len() && ctx.lines[check_idx].is_blank {
263                            actual_end -= 1;
264                        } else {
265                            break;
266                        }
267                    }
268
269                    if actual_end < content_start_line.unwrap() {
270                        actual_end = content_start_line.unwrap();
271                    }
272
273                    return Some(TocRegion {
274                        start_line: content_start_line.unwrap(),
275                        end_line: actual_end,
276                        content_start: content_start_byte.unwrap(),
277                        content_end,
278                    });
279                }
280
281                // Check for two consecutive blank lines
282                if line_info.is_blank {
283                    blank_streak += 1;
284                    if blank_streak >= 2 {
285                        let start_line = content_start_line.unwrap();
286                        let first_blank_idx = idx - 1;
287                        let mut end_line = line_num.saturating_sub(2);
288                        if end_line < start_line {
289                            end_line = start_line;
290                        }
291
292                        return Some(TocRegion {
293                            start_line,
294                            end_line,
295                            content_start: content_start_byte.unwrap(),
296                            content_end: ctx.lines[first_blank_idx].byte_offset,
297                        });
298                    }
299                } else {
300                    blank_streak = 0;
301                }
302            }
303        }
304
305        // If we found a TOC heading but no subsequent heading, TOC goes to end
306        if let (Some(start_line), Some(start_byte)) = (content_start_line, content_start_byte) {
307            // Find last non-blank line
308            let mut end_line = ctx.lines.len();
309            while end_line > start_line {
310                let check_idx = end_line - 1;
311                if check_idx < ctx.lines.len() && ctx.lines[check_idx].is_blank {
312                    end_line -= 1;
313                } else {
314                    break;
315                }
316            }
317
318            return Some(TocRegion {
319                start_line,
320                end_line,
321                content_start: start_byte,
322                content_end: ctx.content.len(),
323            });
324        }
325
326        None
327    }
328
329    /// Detect TOC region based on configured detection method
330    fn detect_toc_region(&self, ctx: &LintContext) -> Option<TocRegion> {
331        match self.detection {
332            TocDetection::Markers => self.detect_by_markers(ctx),
333            TocDetection::Heading => self.detect_by_heading(ctx),
334            TocDetection::Both => {
335                // Try markers first, then fall back to heading
336                self.detect_by_markers(ctx).or_else(|| self.detect_by_heading(ctx))
337            }
338        }
339    }
340
341    /// Extract TOC entries from the detected region
342    fn extract_toc_entries(&self, ctx: &LintContext, region: &TocRegion) -> Vec<TocEntry> {
343        let mut entries = Vec::new();
344
345        for idx in (region.start_line - 1)..region.end_line.min(ctx.lines.len()) {
346            let line_info = &ctx.lines[idx];
347            let content = line_info.content(ctx.content);
348
349            if let Some(caps) = TOC_ENTRY_PATTERN.captures(content) {
350                let text = caps.get(2).map_or("", |m| m.as_str()).to_string();
351                let anchor = caps.get(3).map_or("", |m| m.as_str()).to_string();
352
353                entries.push(TocEntry { text, anchor });
354            }
355        }
356
357        entries
358    }
359
360    /// Build expected TOC entries from document headings
361    fn build_expected_toc(&self, ctx: &LintContext, toc_region: &TocRegion) -> Vec<ExpectedTocEntry> {
362        let mut entries = Vec::new();
363        let mut fragment_counts: HashMap<String, usize> = HashMap::new();
364
365        for (idx, line_info) in ctx.lines.iter().enumerate() {
366            let line_num = idx + 1;
367
368            // Skip headings before/within the TOC region
369            if line_num <= toc_region.end_line {
370                // Also skip the TOC heading itself for heading-based detection
371                continue;
372            }
373
374            // Skip code blocks, front matter, HTML blocks
375            if line_info.in_code_block || line_info.in_front_matter || line_info.in_html_block {
376                continue;
377            }
378
379            if let Some(heading) = &line_info.heading {
380                // Filter by min/max level
381                if heading.level < self.min_level || heading.level > self.max_level {
382                    continue;
383                }
384
385                // Use custom ID if available
386                let base_anchor = if let Some(custom_id) = &heading.custom_id {
387                    custom_id.clone()
388                } else {
389                    self.anchor_style.generate_fragment(&heading.text)
390                };
391
392                // Handle duplicate anchors
393                let anchor = if let Some(count) = fragment_counts.get_mut(&base_anchor) {
394                    let suffix = *count;
395                    *count += 1;
396                    format!("{base_anchor}-{suffix}")
397                } else {
398                    fragment_counts.insert(base_anchor.clone(), 1);
399                    base_anchor
400                };
401
402                entries.push(ExpectedTocEntry {
403                    heading_line: line_num,
404                    level: heading.level,
405                    text: heading.text.clone(),
406                    anchor,
407                });
408            }
409        }
410
411        entries
412    }
413
414    /// Compare actual TOC entries against expected and find mismatches
415    fn validate_toc(&self, actual: &[TocEntry], expected: &[ExpectedTocEntry]) -> Vec<TocMismatch> {
416        let mut mismatches = Vec::new();
417
418        // Build a map of expected anchors
419        let expected_anchors: HashMap<&str, &ExpectedTocEntry> =
420            expected.iter().map(|e| (e.anchor.as_str(), e)).collect();
421
422        // Build a map of actual anchors
423        let actual_anchors: HashMap<&str, &TocEntry> = actual.iter().map(|e| (e.anchor.as_str(), e)).collect();
424
425        // Check for stale entries (in TOC but not in expected)
426        for entry in actual {
427            if !expected_anchors.contains_key(entry.anchor.as_str()) {
428                mismatches.push(TocMismatch::StaleEntry { entry: entry.clone() });
429            }
430        }
431
432        // Check for missing entries (in expected but not in TOC)
433        for exp in expected {
434            if !actual_anchors.contains_key(exp.anchor.as_str()) {
435                mismatches.push(TocMismatch::MissingEntry { expected: exp.clone() });
436            }
437        }
438
439        // Check for text mismatches
440        for entry in actual {
441            if let Some(exp) = expected_anchors.get(entry.anchor.as_str()) {
442                // Normalize comparison (trim, case-insensitive for very flexible matching)
443                if entry.text.trim() != exp.text.trim() {
444                    mismatches.push(TocMismatch::TextMismatch {
445                        entry: entry.clone(),
446                        expected: (*exp).clone(),
447                    });
448                }
449            }
450        }
451
452        // Check order if enforce_order is enabled
453        if self.enforce_order && !actual.is_empty() && !expected.is_empty() {
454            let expected_order: Vec<&str> = expected.iter().map(|e| e.anchor.as_str()).collect();
455
456            // Find entries that exist in both but are out of order
457            let mut expected_idx = 0;
458            for entry in actual {
459                // Skip entries that don't exist in expected
460                if !expected_anchors.contains_key(entry.anchor.as_str()) {
461                    continue;
462                }
463
464                // Find where this anchor should be
465                while expected_idx < expected_order.len() && expected_order[expected_idx] != entry.anchor {
466                    expected_idx += 1;
467                }
468
469                if expected_idx >= expected_order.len() {
470                    // This entry is after where it should be
471                    let correct_pos = expected_order.iter().position(|a| *a == entry.anchor).unwrap_or(0);
472                    // Only add order mismatch if not already reported as stale/text mismatch
473                    let already_reported = mismatches.iter().any(|m| match m {
474                        TocMismatch::StaleEntry { entry: e } => e.anchor == entry.anchor,
475                        TocMismatch::TextMismatch { entry: e, .. } => e.anchor == entry.anchor,
476                        _ => false,
477                    });
478                    if !already_reported {
479                        mismatches.push(TocMismatch::OrderMismatch {
480                            entry: entry.clone(),
481                            expected_position: correct_pos + 1,
482                        });
483                    }
484                } else {
485                    expected_idx += 1;
486                }
487            }
488        }
489
490        mismatches
491    }
492
493    /// Generate a new TOC from expected entries
494    fn generate_toc(&self, expected: &[ExpectedTocEntry]) -> String {
495        if expected.is_empty() {
496            return String::new();
497        }
498
499        let mut result = String::new();
500        let base_level = expected.iter().map(|e| e.level).min().unwrap_or(2);
501
502        for entry in expected {
503            let indent = if self.nested {
504                let level_diff = entry.level.saturating_sub(base_level) as usize;
505                "  ".repeat(level_diff)
506            } else {
507                String::new()
508            };
509
510            result.push_str(&format!("{indent}- [{}](#{})\n", entry.text, entry.anchor));
511        }
512
513        result
514    }
515}
516
517impl Rule for MD073TocValidation {
518    fn name(&self) -> &'static str {
519        "MD073"
520    }
521
522    fn description(&self) -> &'static str {
523        "Table of Contents should match document headings"
524    }
525
526    fn should_skip(&self, ctx: &LintContext) -> bool {
527        // Quick check: if no TOC markers or headings that could be TOC
528        let has_toc_marker = ctx.content.contains("<!-- toc") || ctx.content.contains("<!--toc");
529        let has_toc_heading = self
530            .toc_headings
531            .iter()
532            .any(|h| ctx.content.to_lowercase().contains(&h.to_lowercase()));
533
534        !has_toc_marker && !has_toc_heading
535    }
536
537    fn check(&self, ctx: &LintContext) -> LintResult {
538        let mut warnings = Vec::new();
539
540        // Detect TOC region
541        let Some(region) = self.detect_toc_region(ctx) else {
542            // No TOC found - nothing to validate
543            return Ok(warnings);
544        };
545
546        // Extract actual TOC entries
547        let actual_entries = self.extract_toc_entries(ctx, &region);
548
549        // Build expected TOC from headings
550        let expected_entries = self.build_expected_toc(ctx, &region);
551
552        // If no expected entries and no actual entries, nothing to validate
553        if expected_entries.is_empty() && actual_entries.is_empty() {
554            return Ok(warnings);
555        }
556
557        // Validate
558        let mismatches = self.validate_toc(&actual_entries, &expected_entries);
559
560        if !mismatches.is_empty() {
561            // Generate a single warning at the TOC region with details
562            let mut details = Vec::new();
563
564            for mismatch in &mismatches {
565                match mismatch {
566                    TocMismatch::StaleEntry { entry } => {
567                        details.push(format!("Stale entry: '{}' (heading no longer exists)", entry.text));
568                    }
569                    TocMismatch::MissingEntry { expected } => {
570                        details.push(format!(
571                            "Missing entry: '{}' (line {})",
572                            expected.text, expected.heading_line
573                        ));
574                    }
575                    TocMismatch::TextMismatch { entry, expected } => {
576                        details.push(format!(
577                            "Text mismatch: TOC has '{}', heading is '{}'",
578                            entry.text, expected.text
579                        ));
580                    }
581                    TocMismatch::OrderMismatch {
582                        entry,
583                        expected_position,
584                    } => {
585                        details.push(format!(
586                            "Order mismatch: '{}' should be at position {}",
587                            entry.text, expected_position
588                        ));
589                    }
590                }
591            }
592
593            let message = format!(
594                "Table of Contents does not match document headings: {}",
595                details.join("; ")
596            );
597
598            // Generate fix: replace entire TOC content
599            let new_toc = self.generate_toc(&expected_entries);
600            let fix_range = region.content_start..region.content_end;
601
602            warnings.push(LintWarning {
603                rule_name: Some(self.name().to_string()),
604                message,
605                line: region.start_line,
606                column: 1,
607                end_line: region.end_line,
608                end_column: 1,
609                severity: Severity::Warning,
610                fix: Some(Fix {
611                    range: fix_range,
612                    replacement: new_toc,
613                }),
614            });
615        }
616
617        Ok(warnings)
618    }
619
620    fn fix(&self, ctx: &LintContext) -> Result<String, LintError> {
621        // Detect TOC region
622        let Some(region) = self.detect_toc_region(ctx) else {
623            // No TOC found - return unchanged
624            return Ok(ctx.content.to_string());
625        };
626
627        // Build expected TOC from headings
628        let expected_entries = self.build_expected_toc(ctx, &region);
629
630        // Generate new TOC
631        let new_toc = self.generate_toc(&expected_entries);
632
633        // Replace the TOC content
634        let mut result = String::with_capacity(ctx.content.len());
635        result.push_str(&ctx.content[..region.content_start]);
636        result.push_str(&new_toc);
637        result.push_str(&ctx.content[region.content_end..]);
638
639        Ok(result)
640    }
641
642    fn category(&self) -> RuleCategory {
643        RuleCategory::Other
644    }
645
646    fn as_any(&self) -> &dyn std::any::Any {
647        self
648    }
649
650    fn default_config_section(&self) -> Option<(String, toml::Value)> {
651        let value: toml::Value = toml::from_str(
652            r#"
653# Detection method: "markers", "heading", or "both"
654detection = "both"
655# Minimum heading level to include
656min-level = 2
657# Maximum heading level to include
658max-level = 4
659# Whether TOC order must match document order
660enforce-order = true
661# Whether to use nested indentation
662nested = true
663# Anchor generation style
664anchor-style = "github"
665# Headings that indicate a TOC section
666toc-headings = ["Table of Contents", "Contents", "TOC"]
667"#,
668        )
669        .ok()?;
670        Some(("MD073".to_string(), value))
671    }
672
673    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
674    where
675        Self: Sized,
676    {
677        let mut rule = MD073TocValidation::default();
678
679        if let Some(rule_config) = config.rules.get("MD073") {
680            // Parse detection method
681            if let Some(detection_str) = rule_config.values.get("detection").and_then(|v| v.as_str()) {
682                rule.detection = match detection_str.to_lowercase().as_str() {
683                    "markers" => TocDetection::Markers,
684                    "heading" => TocDetection::Heading,
685                    _ => TocDetection::Both,
686                };
687            }
688
689            // Parse min-level
690            if let Some(min_level) = rule_config.values.get("min-level").and_then(|v| v.as_integer()) {
691                rule.min_level = (min_level.clamp(1, 6)) as u8;
692            }
693
694            // Parse max-level
695            if let Some(max_level) = rule_config.values.get("max-level").and_then(|v| v.as_integer()) {
696                rule.max_level = (max_level.clamp(1, 6)) as u8;
697            }
698
699            // Parse enforce-order
700            if let Some(enforce_order) = rule_config.values.get("enforce-order").and_then(|v| v.as_bool()) {
701                rule.enforce_order = enforce_order;
702            }
703
704            // Parse nested
705            if let Some(nested) = rule_config.values.get("nested").and_then(|v| v.as_bool()) {
706                rule.nested = nested;
707            }
708
709            // Parse anchor-style
710            if let Some(style_str) = rule_config.values.get("anchor-style").and_then(|v| v.as_str()) {
711                rule.anchor_style = match style_str.to_lowercase().as_str() {
712                    "kramdown" => AnchorStyle::Kramdown,
713                    "kramdown-gfm" | "jekyll" => AnchorStyle::KramdownGfm,
714                    _ => AnchorStyle::GitHub,
715                };
716            }
717
718            // Parse toc-headings
719            if let Some(headings) = rule_config.values.get("toc-headings").and_then(|v| v.as_array()) {
720                let custom_headings: Vec<String> = headings
721                    .iter()
722                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
723                    .collect();
724                if !custom_headings.is_empty() {
725                    rule.toc_headings = custom_headings;
726                }
727            }
728        }
729
730        Box::new(rule)
731    }
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use crate::config::MarkdownFlavor;
738
739    fn create_ctx(content: &str) -> LintContext<'_> {
740        LintContext::new(content, MarkdownFlavor::Standard, None)
741    }
742
743    // ========== Detection Tests ==========
744
745    #[test]
746    fn test_detect_markers_basic() {
747        let rule = MD073TocValidation::new();
748        let content = r#"# Title
749
750<!-- toc -->
751
752- [Heading 1](#heading-1)
753
754<!-- tocstop -->
755
756## Heading 1
757
758Content here.
759"#;
760        let ctx = create_ctx(content);
761        let region = rule.detect_by_markers(&ctx);
762        assert!(region.is_some());
763        let region = region.unwrap();
764        // Verify region boundaries are detected correctly
765        assert_eq!(region.start_line, 4);
766        assert_eq!(region.end_line, 6);
767    }
768
769    #[test]
770    fn test_detect_markers_variations() {
771        let rule = MD073TocValidation::new();
772
773        // Test <!--toc--> (no spaces)
774        let content1 = "<!--toc-->\n- [A](#a)\n<!--tocstop-->\n";
775        let ctx1 = create_ctx(content1);
776        assert!(rule.detect_by_markers(&ctx1).is_some());
777
778        // Test <!-- TOC --> (uppercase)
779        let content2 = "<!-- TOC -->\n- [A](#a)\n<!-- TOCSTOP -->\n";
780        let ctx2 = create_ctx(content2);
781        assert!(rule.detect_by_markers(&ctx2).is_some());
782
783        // Test <!-- /toc --> (alternative stop marker)
784        let content3 = "<!-- toc -->\n- [A](#a)\n<!-- /toc -->\n";
785        let ctx3 = create_ctx(content3);
786        assert!(rule.detect_by_markers(&ctx3).is_some());
787    }
788
789    #[test]
790    fn test_detect_heading_table_of_contents() {
791        let mut rule = MD073TocValidation::new();
792        rule.detection = TocDetection::Heading;
793
794        let content = r#"# Title
795
796## Table of Contents
797
798- [Heading 1](#heading-1)
799- [Heading 2](#heading-2)
800
801## Heading 1
802
803Content.
804
805## Heading 2
806
807More content.
808"#;
809        let ctx = create_ctx(content);
810        let region = rule.detect_by_heading(&ctx);
811        assert!(region.is_some());
812        let region = region.unwrap();
813        // Verify the TOC region was detected
814        assert_eq!(region.start_line, 4);
815    }
816
817    #[test]
818    fn test_detect_heading_ends_on_double_blank_lines() {
819        let mut rule = MD073TocValidation::new();
820        rule.detection = TocDetection::Heading;
821
822        let content = r#"# Title
823
824## Table of Contents
825
826- [Heading 1](#heading-1)
827
828
829This text is not part of TOC.
830
831## Heading 1
832
833Content.
834"#;
835        let ctx = create_ctx(content);
836        let region = rule.detect_by_heading(&ctx).unwrap();
837        assert_eq!(region.start_line, 4);
838        assert_eq!(region.end_line, 5);
839    }
840
841    #[test]
842    fn test_no_toc_region() {
843        let rule = MD073TocValidation::new();
844        let content = r#"# Title
845
846## Heading 1
847
848Content here.
849
850## Heading 2
851
852More content.
853"#;
854        let ctx = create_ctx(content);
855        let region = rule.detect_toc_region(&ctx);
856        assert!(region.is_none());
857    }
858
859    // ========== Validation Tests ==========
860
861    #[test]
862    fn test_toc_matches_headings() {
863        let rule = MD073TocValidation::new();
864        let content = r#"# Title
865
866<!-- toc -->
867
868- [Heading 1](#heading-1)
869- [Heading 2](#heading-2)
870
871<!-- tocstop -->
872
873## Heading 1
874
875Content.
876
877## Heading 2
878
879More content.
880"#;
881        let ctx = create_ctx(content);
882        let result = rule.check(&ctx).unwrap();
883        assert!(result.is_empty(), "Expected no warnings for matching TOC");
884    }
885
886    #[test]
887    fn test_missing_entry() {
888        let rule = MD073TocValidation::new();
889        let content = r#"# Title
890
891<!-- toc -->
892
893- [Heading 1](#heading-1)
894
895<!-- tocstop -->
896
897## Heading 1
898
899Content.
900
901## Heading 2
902
903New heading not in TOC.
904"#;
905        let ctx = create_ctx(content);
906        let result = rule.check(&ctx).unwrap();
907        assert_eq!(result.len(), 1);
908        assert!(result[0].message.contains("Missing entry"));
909        assert!(result[0].message.contains("Heading 2"));
910    }
911
912    #[test]
913    fn test_stale_entry() {
914        let rule = MD073TocValidation::new();
915        let content = r#"# Title
916
917<!-- toc -->
918
919- [Heading 1](#heading-1)
920- [Deleted Heading](#deleted-heading)
921
922<!-- tocstop -->
923
924## Heading 1
925
926Content.
927"#;
928        let ctx = create_ctx(content);
929        let result = rule.check(&ctx).unwrap();
930        assert_eq!(result.len(), 1);
931        assert!(result[0].message.contains("Stale entry"));
932        assert!(result[0].message.contains("Deleted Heading"));
933    }
934
935    #[test]
936    fn test_text_mismatch() {
937        let rule = MD073TocValidation::new();
938        let content = r#"# Title
939
940<!-- toc -->
941
942- [Old Name](#heading-1)
943
944<!-- tocstop -->
945
946## Heading 1
947
948Content.
949"#;
950        let ctx = create_ctx(content);
951        let result = rule.check(&ctx).unwrap();
952        assert_eq!(result.len(), 1);
953        assert!(result[0].message.contains("Text mismatch"));
954    }
955
956    // ========== Level Filtering Tests ==========
957
958    #[test]
959    fn test_min_level_excludes_h1() {
960        let mut rule = MD073TocValidation::new();
961        rule.min_level = 2;
962
963        let content = r#"<!-- toc -->
964
965<!-- tocstop -->
966
967# Should Be Excluded
968
969## Should Be Included
970
971Content.
972"#;
973        let ctx = create_ctx(content);
974        let region = rule.detect_toc_region(&ctx).unwrap();
975        let expected = rule.build_expected_toc(&ctx, &region);
976
977        assert_eq!(expected.len(), 1);
978        assert_eq!(expected[0].text, "Should Be Included");
979    }
980
981    #[test]
982    fn test_max_level_excludes_h5_h6() {
983        let mut rule = MD073TocValidation::new();
984        rule.max_level = 4;
985
986        let content = r#"<!-- toc -->
987
988<!-- tocstop -->
989
990## Level 2
991
992### Level 3
993
994#### Level 4
995
996##### Level 5 Should Be Excluded
997
998###### Level 6 Should Be Excluded
999"#;
1000        let ctx = create_ctx(content);
1001        let region = rule.detect_toc_region(&ctx).unwrap();
1002        let expected = rule.build_expected_toc(&ctx, &region);
1003
1004        assert_eq!(expected.len(), 3);
1005        assert!(expected.iter().all(|e| e.level <= 4));
1006    }
1007
1008    // ========== Fix Tests ==========
1009
1010    #[test]
1011    fn test_fix_adds_missing_entry() {
1012        let rule = MD073TocValidation::new();
1013        let content = r#"# Title
1014
1015<!-- toc -->
1016
1017- [Heading 1](#heading-1)
1018
1019<!-- tocstop -->
1020
1021## Heading 1
1022
1023Content.
1024
1025## Heading 2
1026
1027New heading.
1028"#;
1029        let ctx = create_ctx(content);
1030        let fixed = rule.fix(&ctx).unwrap();
1031        assert!(fixed.contains("- [Heading 2](#heading-2)"));
1032    }
1033
1034    #[test]
1035    fn test_fix_removes_stale_entry() {
1036        let rule = MD073TocValidation::new();
1037        let content = r#"# Title
1038
1039<!-- toc -->
1040
1041- [Heading 1](#heading-1)
1042- [Deleted](#deleted)
1043
1044<!-- tocstop -->
1045
1046## Heading 1
1047
1048Content.
1049"#;
1050        let ctx = create_ctx(content);
1051        let fixed = rule.fix(&ctx).unwrap();
1052        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1053        assert!(!fixed.contains("Deleted"));
1054    }
1055
1056    #[test]
1057    fn test_fix_idempotent() {
1058        let rule = MD073TocValidation::new();
1059        let content = r#"# Title
1060
1061<!-- toc -->
1062
1063- [Heading 1](#heading-1)
1064- [Heading 2](#heading-2)
1065
1066<!-- tocstop -->
1067
1068## Heading 1
1069
1070Content.
1071
1072## Heading 2
1073
1074More.
1075"#;
1076        let ctx = create_ctx(content);
1077        let fixed1 = rule.fix(&ctx).unwrap();
1078        let ctx2 = create_ctx(&fixed1);
1079        let fixed2 = rule.fix(&ctx2).unwrap();
1080
1081        // Second fix should produce same output
1082        assert_eq!(fixed1, fixed2);
1083    }
1084
1085    #[test]
1086    fn test_fix_preserves_markers() {
1087        let rule = MD073TocValidation::new();
1088        let content = r#"# Title
1089
1090<!-- toc -->
1091
1092Old TOC content.
1093
1094<!-- tocstop -->
1095
1096## New Heading
1097
1098Content.
1099"#;
1100        let ctx = create_ctx(content);
1101        let fixed = rule.fix(&ctx).unwrap();
1102
1103        // Markers should still be present
1104        assert!(fixed.contains("<!-- toc -->"));
1105        assert!(fixed.contains("<!-- tocstop -->"));
1106        // New content should be generated
1107        assert!(fixed.contains("- [New Heading](#new-heading)"));
1108    }
1109
1110    #[test]
1111    fn test_fix_heading_detection_stops_on_double_blank_lines() {
1112        let mut rule = MD073TocValidation::new();
1113        rule.detection = TocDetection::Heading;
1114
1115        let content = r#"# Title
1116
1117## Table of Contents
1118
1119- [Heading 1](#heading-1)
1120
1121
1122This text is not part of TOC.
1123
1124## Heading 1
1125
1126Content.
1127"#;
1128        let ctx = create_ctx(content);
1129        let fixed = rule.fix(&ctx).unwrap();
1130
1131        assert!(fixed.contains("- [Heading 1](#heading-1)"));
1132        assert!(fixed.contains("This text is not part of TOC."));
1133    }
1134
1135    // ========== Anchor Tests ==========
1136
1137    #[test]
1138    fn test_duplicate_heading_anchors() {
1139        let rule = MD073TocValidation::new();
1140        let content = r#"# Title
1141
1142<!-- toc -->
1143
1144<!-- tocstop -->
1145
1146## Duplicate
1147
1148Content.
1149
1150## Duplicate
1151
1152More content.
1153
1154## Duplicate
1155
1156Even more.
1157"#;
1158        let ctx = create_ctx(content);
1159        let region = rule.detect_toc_region(&ctx).unwrap();
1160        let expected = rule.build_expected_toc(&ctx, &region);
1161
1162        assert_eq!(expected.len(), 3);
1163        assert_eq!(expected[0].anchor, "duplicate");
1164        assert_eq!(expected[1].anchor, "duplicate-1");
1165        assert_eq!(expected[2].anchor, "duplicate-2");
1166    }
1167
1168    // ========== Edge Cases ==========
1169
1170    #[test]
1171    fn test_headings_in_code_blocks_ignored() {
1172        let rule = MD073TocValidation::new();
1173        let content = r#"# Title
1174
1175<!-- toc -->
1176
1177- [Real Heading](#real-heading)
1178
1179<!-- tocstop -->
1180
1181## Real Heading
1182
1183```markdown
1184## Fake Heading In Code
1185```
1186
1187Content.
1188"#;
1189        let ctx = create_ctx(content);
1190        let result = rule.check(&ctx).unwrap();
1191        assert!(result.is_empty(), "Should not report fake heading in code block");
1192    }
1193
1194    #[test]
1195    fn test_empty_toc_region() {
1196        let rule = MD073TocValidation::new();
1197        let content = r#"# Title
1198
1199<!-- toc -->
1200<!-- tocstop -->
1201
1202## Heading 1
1203
1204Content.
1205"#;
1206        let ctx = create_ctx(content);
1207        let result = rule.check(&ctx).unwrap();
1208        assert_eq!(result.len(), 1);
1209        assert!(result[0].message.contains("Missing entry"));
1210    }
1211
1212    #[test]
1213    fn test_nested_indentation() {
1214        let mut rule = MD073TocValidation::new();
1215        rule.nested = true;
1216
1217        let content = r#"<!-- toc -->
1218
1219<!-- tocstop -->
1220
1221## Level 2
1222
1223### Level 3
1224
1225#### Level 4
1226
1227## Another Level 2
1228"#;
1229        let ctx = create_ctx(content);
1230        let region = rule.detect_toc_region(&ctx).unwrap();
1231        let expected = rule.build_expected_toc(&ctx, &region);
1232        let toc = rule.generate_toc(&expected);
1233
1234        // Check indentation
1235        assert!(toc.contains("- [Level 2](#level-2)"));
1236        assert!(toc.contains("  - [Level 3](#level-3)"));
1237        assert!(toc.contains("    - [Level 4](#level-4)"));
1238        assert!(toc.contains("- [Another Level 2](#another-level-2)"));
1239    }
1240
1241    #[test]
1242    fn test_flat_no_indentation() {
1243        let mut rule = MD073TocValidation::new();
1244        rule.nested = false;
1245
1246        let content = r#"<!-- toc -->
1247
1248<!-- tocstop -->
1249
1250## Level 2
1251
1252### Level 3
1253
1254#### Level 4
1255"#;
1256        let ctx = create_ctx(content);
1257        let region = rule.detect_toc_region(&ctx).unwrap();
1258        let expected = rule.build_expected_toc(&ctx, &region);
1259        let toc = rule.generate_toc(&expected);
1260
1261        // All entries should have no indentation
1262        for line in toc.lines() {
1263            if !line.is_empty() {
1264                assert!(line.starts_with("- ["), "Line should start without indent: {line}");
1265            }
1266        }
1267    }
1268
1269    // ========== Order Mismatch Tests ==========
1270
1271    #[test]
1272    fn test_order_mismatch_detected() {
1273        let rule = MD073TocValidation::new();
1274        let content = r#"# Title
1275
1276<!-- toc -->
1277
1278- [Section B](#section-b)
1279- [Section A](#section-a)
1280
1281<!-- tocstop -->
1282
1283## Section A
1284
1285Content A.
1286
1287## Section B
1288
1289Content B.
1290"#;
1291        let ctx = create_ctx(content);
1292        let result = rule.check(&ctx).unwrap();
1293        // Should detect order mismatch - Section B appears before Section A in TOC
1294        // but Section A comes first in document
1295        assert!(!result.is_empty(), "Should detect order mismatch");
1296    }
1297
1298    #[test]
1299    fn test_order_mismatch_ignored_when_disabled() {
1300        let mut rule = MD073TocValidation::new();
1301        rule.enforce_order = false;
1302        let content = r#"# Title
1303
1304<!-- toc -->
1305
1306- [Section B](#section-b)
1307- [Section A](#section-a)
1308
1309<!-- tocstop -->
1310
1311## Section A
1312
1313Content A.
1314
1315## Section B
1316
1317Content B.
1318"#;
1319        let ctx = create_ctx(content);
1320        let result = rule.check(&ctx).unwrap();
1321        // With enforce_order=false, order mismatches should be ignored
1322        assert!(result.is_empty(), "Should not report order mismatch when disabled");
1323    }
1324
1325    // ========== Unicode and Special Characters Tests ==========
1326
1327    #[test]
1328    fn test_unicode_headings() {
1329        let rule = MD073TocValidation::new();
1330        let content = r#"# Title
1331
1332<!-- toc -->
1333
1334- [日本語の見出し](#日本語の見出し)
1335- [Émojis 🎉](#émojis-)
1336
1337<!-- tocstop -->
1338
1339## 日本語の見出し
1340
1341Japanese content.
1342
1343## Émojis 🎉
1344
1345Content with emojis.
1346"#;
1347        let ctx = create_ctx(content);
1348        let result = rule.check(&ctx).unwrap();
1349        // Should handle unicode correctly
1350        assert!(result.is_empty(), "Should handle unicode headings");
1351    }
1352
1353    #[test]
1354    fn test_special_characters_in_headings() {
1355        let rule = MD073TocValidation::new();
1356        let content = r#"# Title
1357
1358<!-- toc -->
1359
1360- [What's New?](#whats-new)
1361- [C++ Guide](#c-guide)
1362
1363<!-- tocstop -->
1364
1365## What's New?
1366
1367News content.
1368
1369## C++ Guide
1370
1371C++ content.
1372"#;
1373        let ctx = create_ctx(content);
1374        let result = rule.check(&ctx).unwrap();
1375        assert!(result.is_empty(), "Should handle special characters");
1376    }
1377
1378    #[test]
1379    fn test_code_spans_in_headings() {
1380        let rule = MD073TocValidation::new();
1381        let content = r#"# Title
1382
1383<!-- toc -->
1384
1385- [`check [PATHS...]`](#check-paths)
1386
1387<!-- tocstop -->
1388
1389## `check [PATHS...]`
1390
1391Command documentation.
1392"#;
1393        let ctx = create_ctx(content);
1394        let result = rule.check(&ctx).unwrap();
1395        assert!(result.is_empty(), "Should handle code spans in headings with brackets");
1396    }
1397
1398    // ========== Config Tests ==========
1399
1400    #[test]
1401    fn test_from_config_defaults() {
1402        let config = crate::config::Config::default();
1403        let rule = MD073TocValidation::from_config(&config);
1404        let rule = rule.as_any().downcast_ref::<MD073TocValidation>().unwrap();
1405
1406        assert_eq!(rule.min_level, 2);
1407        assert_eq!(rule.max_level, 4);
1408        assert!(rule.enforce_order);
1409        assert!(rule.nested);
1410    }
1411
1412    // ========== Custom Anchor Tests ==========
1413
1414    #[test]
1415    fn test_custom_anchor_id_respected() {
1416        let rule = MD073TocValidation::new();
1417        let content = r#"# Title
1418
1419<!-- toc -->
1420
1421- [My Section](#my-custom-anchor)
1422
1423<!-- tocstop -->
1424
1425## My Section {#my-custom-anchor}
1426
1427Content here.
1428"#;
1429        let ctx = create_ctx(content);
1430        let result = rule.check(&ctx).unwrap();
1431        assert!(result.is_empty(), "Should respect custom anchor IDs: {result:?}");
1432    }
1433
1434    #[test]
1435    fn test_custom_anchor_id_in_generated_toc() {
1436        let rule = MD073TocValidation::new();
1437        let content = r#"# Title
1438
1439<!-- toc -->
1440
1441<!-- tocstop -->
1442
1443## First Section {#custom-first}
1444
1445Content.
1446
1447## Second Section {#another-custom}
1448
1449More content.
1450"#;
1451        let ctx = create_ctx(content);
1452        let fixed = rule.fix(&ctx).unwrap();
1453        assert!(fixed.contains("- [First Section](#custom-first)"));
1454        assert!(fixed.contains("- [Second Section](#another-custom)"));
1455    }
1456
1457    #[test]
1458    fn test_mixed_custom_and_generated_anchors() {
1459        let rule = MD073TocValidation::new();
1460        let content = r#"# Title
1461
1462<!-- toc -->
1463
1464- [Custom Section](#my-id)
1465- [Normal Section](#normal-section)
1466
1467<!-- tocstop -->
1468
1469## Custom Section {#my-id}
1470
1471Content.
1472
1473## Normal Section
1474
1475More content.
1476"#;
1477        let ctx = create_ctx(content);
1478        let result = rule.check(&ctx).unwrap();
1479        assert!(result.is_empty(), "Should handle mixed custom and generated anchors");
1480    }
1481
1482    // ========== Anchor Style Tests ==========
1483
1484    #[test]
1485    fn test_github_anchor_style_default() {
1486        let rule = MD073TocValidation::new();
1487        assert_eq!(rule.anchor_style, AnchorStyle::GitHub);
1488
1489        let content = r#"<!-- toc -->
1490
1491<!-- tocstop -->
1492
1493## Test_With_Underscores
1494
1495Content.
1496"#;
1497        let ctx = create_ctx(content);
1498        let region = rule.detect_toc_region(&ctx).unwrap();
1499        let expected = rule.build_expected_toc(&ctx, &region);
1500
1501        // GitHub preserves underscores
1502        assert_eq!(expected[0].anchor, "test_with_underscores");
1503    }
1504
1505    #[test]
1506    fn test_kramdown_anchor_style() {
1507        let mut rule = MD073TocValidation::new();
1508        rule.anchor_style = AnchorStyle::Kramdown;
1509
1510        let content = r#"<!-- toc -->
1511
1512<!-- tocstop -->
1513
1514## Test_With_Underscores
1515
1516Content.
1517"#;
1518        let ctx = create_ctx(content);
1519        let region = rule.detect_toc_region(&ctx).unwrap();
1520        let expected = rule.build_expected_toc(&ctx, &region);
1521
1522        // Kramdown removes underscores
1523        assert_eq!(expected[0].anchor, "testwithunderscores");
1524    }
1525
1526    #[test]
1527    fn test_kramdown_gfm_anchor_style() {
1528        let mut rule = MD073TocValidation::new();
1529        rule.anchor_style = AnchorStyle::KramdownGfm;
1530
1531        let content = r#"<!-- toc -->
1532
1533<!-- tocstop -->
1534
1535## Test_With_Underscores
1536
1537Content.
1538"#;
1539        let ctx = create_ctx(content);
1540        let region = rule.detect_toc_region(&ctx).unwrap();
1541        let expected = rule.build_expected_toc(&ctx, &region);
1542
1543        // KramdownGfm preserves underscores
1544        assert_eq!(expected[0].anchor, "test_with_underscores");
1545    }
1546
1547    // ========== Stress Tests ==========
1548
1549    #[test]
1550    fn test_stress_many_headings() {
1551        let rule = MD073TocValidation::new();
1552
1553        // Generate a document with 150 headings
1554        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1555
1556        for i in 1..=150 {
1557            content.push_str(&format!("## Heading Number {i}\n\nContent for section {i}.\n\n"));
1558        }
1559
1560        let ctx = create_ctx(&content);
1561
1562        // Should not panic or timeout
1563        let result = rule.check(&ctx).unwrap();
1564
1565        // Should report missing entries for all 150 headings
1566        assert_eq!(result.len(), 1, "Should report single warning for TOC");
1567        assert!(result[0].message.contains("Missing entry"));
1568
1569        // Fix should generate TOC with 150 entries
1570        let fixed = rule.fix(&ctx).unwrap();
1571        assert!(fixed.contains("- [Heading Number 1](#heading-number-1)"));
1572        assert!(fixed.contains("- [Heading Number 100](#heading-number-100)"));
1573        assert!(fixed.contains("- [Heading Number 150](#heading-number-150)"));
1574    }
1575
1576    #[test]
1577    fn test_stress_deeply_nested() {
1578        let rule = MD073TocValidation::new();
1579        let content = r#"# Title
1580
1581<!-- toc -->
1582
1583<!-- tocstop -->
1584
1585## Level 2 A
1586
1587### Level 3 A
1588
1589#### Level 4 A
1590
1591## Level 2 B
1592
1593### Level 3 B
1594
1595#### Level 4 B
1596
1597## Level 2 C
1598
1599### Level 3 C
1600
1601#### Level 4 C
1602
1603## Level 2 D
1604
1605### Level 3 D
1606
1607#### Level 4 D
1608"#;
1609        let ctx = create_ctx(content);
1610        let fixed = rule.fix(&ctx).unwrap();
1611
1612        // Check nested indentation is correct
1613        assert!(fixed.contains("- [Level 2 A](#level-2-a)"));
1614        assert!(fixed.contains("  - [Level 3 A](#level-3-a)"));
1615        assert!(fixed.contains("    - [Level 4 A](#level-4-a)"));
1616        assert!(fixed.contains("- [Level 2 D](#level-2-d)"));
1617        assert!(fixed.contains("  - [Level 3 D](#level-3-d)"));
1618        assert!(fixed.contains("    - [Level 4 D](#level-4-d)"));
1619    }
1620
1621    #[test]
1622    fn test_stress_many_duplicates() {
1623        let rule = MD073TocValidation::new();
1624
1625        // Generate 50 headings with the same text
1626        let mut content = String::from("# Title\n\n<!-- toc -->\n\n<!-- tocstop -->\n\n");
1627        for _ in 0..50 {
1628            content.push_str("## FAQ\n\nContent.\n\n");
1629        }
1630
1631        let ctx = create_ctx(&content);
1632        let region = rule.detect_toc_region(&ctx).unwrap();
1633        let expected = rule.build_expected_toc(&ctx, &region);
1634
1635        // Should generate unique anchors for all 50
1636        assert_eq!(expected.len(), 50);
1637        assert_eq!(expected[0].anchor, "faq");
1638        assert_eq!(expected[1].anchor, "faq-1");
1639        assert_eq!(expected[49].anchor, "faq-49");
1640    }
1641}