Skip to main content

rumdl_lib/
workspace_index.rs

1//! Workspace-wide index for cross-file analysis
2//!
3//! This module provides infrastructure for rules that need to validate
4//! references across multiple files, such as MD051 which validates that
5//! cross-file link fragments point to valid headings.
6//!
7//! The index is built in parallel and designed for minimal memory overhead.
8//!
9//! ## Cache Format
10//!
11//! The workspace index can be persisted to disk for faster startup on
12//! repeated runs. The cache format includes a version header to detect
13//! incompatible format changes:
14//!
15//! ```text
16//! [4 bytes: magic "RWSI" - Rumdl Workspace Index]
17//! [4 bytes: format version (u32 little-endian)]
18//! [N bytes: postcard-serialized WorkspaceIndex]
19//! ```
20
21use regex::Regex;
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, HashSet};
24use std::path::{Path, PathBuf};
25use std::sync::LazyLock;
26
27use crate::lint_context::LintContext;
28use crate::utils::range_utils::byte_to_char_count;
29
30// =============================================================================
31// URL Decoding Helper
32// =============================================================================
33
34/// Convert a hex digit character to its numeric value (0-15)
35fn hex_digit_to_value(c: u8) -> Option<u8> {
36    match c {
37        b'0'..=b'9' => Some(c - b'0'),
38        b'a'..=b'f' => Some(c - b'a' + 10),
39        b'A'..=b'F' => Some(c - b'A' + 10),
40        _ => None,
41    }
42}
43
44/// URL-decode a string, handling percent-encoded characters.
45/// Returns the decoded string, or the original if decoding fails.
46/// Used for matching URL-encoded CJK fragments against raw anchors.
47fn url_decode(s: &str) -> String {
48    // Fast path: no percent signs means no encoding
49    if !s.contains('%') {
50        return s.to_string();
51    }
52
53    let bytes = s.as_bytes();
54    let mut result = Vec::with_capacity(bytes.len());
55    let mut i = 0;
56
57    while i < bytes.len() {
58        if bytes[i] == b'%' && i + 2 < bytes.len() {
59            // Try to parse the two hex digits following %
60            let hex1 = bytes[i + 1];
61            let hex2 = bytes[i + 2];
62            if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
63                result.push(d1 * 16 + d2);
64                i += 3;
65                continue;
66            }
67        }
68        result.push(bytes[i]);
69        i += 1;
70    }
71
72    // Convert to UTF-8, falling back to original if invalid
73    String::from_utf8(result).unwrap_or_else(|_| s.to_string())
74}
75
76// =============================================================================
77// Shared cross-file link extraction utilities
78//
79// These regexes and helpers are the canonical implementation for extracting
80// cross-file links. Both MD057 and LSP use this shared code path for correct
81// position tracking.
82// =============================================================================
83
84/// Regex to match the start of a link
85static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
86
87/// Regex to extract the URL from an angle-bracketed markdown link
88/// Format: `](<URL>)` or `](<URL> "title")`
89static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
90    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
91
92/// Regex to extract the URL from a normal markdown link (without angle brackets)
93/// Format: `](URL)` or `](URL "title")`
94static URL_EXTRACT_REGEX: LazyLock<Regex> =
95    LazyLock::new(|| Regex::new(r#"]\(\s*([^>)\s#]+)(#[^)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
96
97/// Regex to detect URLs with explicit schemes
98pub(crate) static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
99    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
100
101/// Check if a link-target path has a markdown extension (case-insensitive)
102#[inline]
103fn is_markdown_file(path: &str) -> bool {
104    crate::discovery::has_markdown_extension(std::path::Path::new(path))
105}
106
107/// Strip query parameters and fragments from a URL path
108/// Returns the path portion before `?` or `#`
109fn strip_query_and_fragment(url: &str) -> &str {
110    let query_pos = url.find('?');
111    let fragment_pos = url.find('#');
112
113    match (query_pos, fragment_pos) {
114        (Some(q), Some(f)) => &url[..q.min(f)],
115        (Some(q), None) => &url[..q],
116        (None, Some(f)) => &url[..f],
117        (None, None) => url,
118    }
119}
120
121/// The file a directory-relative link names, resolved against the directory
122/// holding the document that wrote it.
123///
124/// The index keeps a destination as the document spelled it, because that is the
125/// text an edit to the link has to be measured against, and a spelling can carry
126/// a query string. A query is not part of a file name - no file is ever called
127/// `b.md?raw=true` - so it is stripped here. Every consumer asking which file a
128/// link points at goes through this, so the index's own keys and the answers
129/// navigation gives cannot disagree.
130pub fn link_target_file(source_dir: &Path, target_path: &str) -> PathBuf {
131    normalize_relative_path(&source_dir.join(strip_query_and_fragment(target_path)))
132}
133
134/// Markdown file links extracted from a document, split by how they resolve.
135///
136/// Linting rules only understand `relative` links (resolved against the source
137/// file's directory). `root_relative` links (leading `/`) are an LSP concept
138/// resolved against the configured content roots, so they are kept separate to
139/// avoid changing linting behavior.
140#[derive(Debug, Default)]
141pub struct ExtractedCrossFileLinks {
142    /// Links resolved relative to the source file's directory.
143    pub relative: Vec<CrossFileLinkIndex>,
144    /// Root-relative links. `target_path` has the leading `/` stripped so it can
145    /// be joined directly to a content root. Parent-traversal and
146    /// protocol-relative (`//host`) links are excluded.
147    pub root_relative: Vec<CrossFileLinkIndex>,
148}
149
150/// Extract cross-file links from content using correct regex-based position tracking.
151///
152/// This is the canonical implementation used by both MD057 and LSP to ensure
153/// consistent and correct column positions for diagnostic reporting.
154///
155/// Returns one `CrossFileLinkIndex` per markdown file link, split into directory
156/// relative links and root-relative links (see `ExtractedCrossFileLinks`).
157pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
158    let content = ctx.content;
159
160    // Early returns for performance
161    if content.is_empty() || !content.contains("](") {
162        return ExtractedCrossFileLinks::default();
163    }
164
165    let mut links = ExtractedCrossFileLinks::default();
166    let lines: Vec<&str> = content.lines().collect();
167
168    // Track which lines we've already processed to avoid duplicates
169    // (ctx.links() may have multiple entries for the same line)
170    let mut processed_lines = HashSet::new();
171
172    for link in ctx.links() {
173        let line_idx = link.line - 1;
174        if line_idx >= lines.len() {
175            continue;
176        }
177
178        // Skip if we've already processed this line
179        if !processed_lines.insert(line_idx) {
180            continue;
181        }
182
183        let line = lines[line_idx];
184        if !line.contains("](") {
185            continue;
186        }
187
188        // Find all links in this line
189        for link_match in LINK_START_REGEX.find_iter(line) {
190            let start_pos = link_match.start();
191            let end_pos = link_match.end();
192
193            // Calculate absolute position for code span detection
194            let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
195            let absolute_start_pos = line_start_byte + start_pos;
196
197            // Skip if in code span
198            if ctx.is_in_code_span_byte(absolute_start_pos) {
199                continue;
200            }
201
202            // Extract the URL (group 1) and fragment (group 2)
203            // Try angle-bracket regex first (handles URLs with parens)
204            let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
205                .captures_at(line, end_pos - 1)
206                .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
207
208            if let Some(caps) = caps_result
209                && let Some(url_group) = caps.get(1)
210            {
211                let file_path = url_group.as_str().trim();
212
213                // Root-relative links (leading `/`) resolve against content roots
214                // in the LSP, not the source directory, so they are captured in a
215                // separate bucket. Protocol-relative (`//host`) and parent-traversal
216                // links are excluded so they cannot escape a content root.
217                if let Some(rel) = file_path.strip_prefix('/') {
218                    if !rel.starts_with('/')
219                        && !Path::new(rel)
220                            .components()
221                            .any(|c| matches!(c, std::path::Component::ParentDir))
222                    {
223                        let stripped = strip_query_and_fragment(rel);
224                        if is_markdown_file(stripped) {
225                            let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
226                            links.root_relative.push(CrossFileLinkIndex {
227                                target_path: stripped.to_string(),
228                                fragment: fragment.to_string(),
229                                line: link.line,
230                                column: byte_to_char_count(line, url_group.start()),
231                                origin: LinkOrigin::Body,
232                            });
233                        }
234                    }
235                    continue;
236                }
237
238                // Skip empty, external, template variables, framework aliases,
239                // fragment-only URLs, or rustdoc intra-doc links
240                if file_path.is_empty()
241                    || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
242                    || file_path.starts_with("www.")
243                    || file_path.starts_with('#')
244                    || file_path.starts_with("{{")
245                    || file_path.starts_with("{%")
246                    || file_path.starts_with('~')
247                    || file_path.starts_with('@')
248                    || (file_path.starts_with('`') && file_path.ends_with('`'))
249                {
250                    continue;
251                }
252
253                // Strip query parameters before indexing
254                let file_path = strip_query_and_fragment(file_path);
255
256                // Get fragment from capture group 2 (includes # prefix)
257                let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
258
259                // Only index markdown file links for cross-file validation
260                if is_markdown_file(file_path) {
261                    links.relative.push(CrossFileLinkIndex {
262                        target_path: file_path.to_string(),
263                        fragment: fragment.to_string(),
264                        line: link.line,
265                        column: byte_to_char_count(line, url_group.start()),
266                        origin: LinkOrigin::Body,
267                    });
268                }
269            }
270        }
271    }
272
273    links
274}
275
276/// Magic bytes identifying a workspace index cache file
277#[cfg(feature = "postcard")]
278const CACHE_MAGIC: &[u8; 4] = b"RWSI";
279
280/// Cache format version - increment when WorkspaceIndex serialization changes
281/// or when the meaning of persisted fields changes such that older caches are
282/// no longer correct. Version 9 adds `CrossFileLinkIndex::origin`; postcard is
283/// not self-describing, so a version 8 cache would decode the following field's
284/// bytes as the new one and yield nonsense.
285///
286/// Version 10 changes what `cross_file_links` holds rather than how it is laid
287/// out: one link is now one entry however each rule spells the destination. The
288/// bytes still decode, so nothing here would notice, and a cached index is
289/// reused whole when a file's content is unchanged - a version 9 cache would
290/// keep reporting the duplicate this version exists to stop.
291///
292/// Version 11 is the same shape of change: a file's entry no longer depends on
293/// its own `per-file-ignores`, so an entry written before it can be missing the
294/// headings an ignored rule would have recorded. Content is what decides reuse,
295/// and the content did not change, so without this the fixed build would keep
296/// serving the false positive from the cache the old one left behind.
297#[cfg(feature = "postcard")]
298const CACHE_FORMAT_VERSION: u32 = 11;
299
300/// Cache file name within the version directory
301#[cfg(feature = "postcard")]
302const CACHE_FILE_NAME: &str = "workspace_index.bin";
303
304/// Monotonic counter making cache temp-file names unique per write, mirroring
305/// `cache.rs`. Combined with the process id (where available) it keeps
306/// concurrent writers from colliding on the temp path before the atomic rename.
307#[cfg(feature = "postcard")]
308static CACHE_TEMP_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
309
310/// Workspace-wide index for cross-file analysis
311///
312/// Contains pre-extracted information from all markdown files in the workspace,
313/// enabling rules to validate cross-file references efficiently.
314#[derive(Debug, Default, Clone, Serialize, Deserialize)]
315pub struct WorkspaceIndex {
316    /// Map from file path to its extracted data
317    files: HashMap<PathBuf, FileIndex>,
318    /// Reverse dependency graph: target file → files that link to it
319    /// Used to efficiently re-lint dependent files when a target changes
320    reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
321    /// Version counter for cache invalidation (incremented on any change)
322    version: u64,
323}
324
325/// Index data extracted from a single file
326#[derive(Debug, Clone, Default, Serialize, Deserialize)]
327pub struct FileIndex {
328    /// Headings in this file with their anchors
329    pub headings: Vec<HeadingIndex>,
330    /// Reference links in this file (for cross-file analysis)
331    pub reference_links: Vec<ReferenceLinkIndex>,
332    /// Cross-file links in this file (for MD051 cross-file validation)
333    pub cross_file_links: Vec<CrossFileLinkIndex>,
334    /// Root-relative links (leading `/`) in this file. Resolved against the
335    /// configured content roots by the LSP for go-to-definition, hover, and
336    /// find-references. `target_path` has the leading `/` stripped. Linting does
337    /// not use these, so they never affect diagnostics.
338    #[serde(default)]
339    pub root_relative_links: Vec<CrossFileLinkIndex>,
340    /// Defined reference IDs (e.g., from `[ref]: url` definitions)
341    /// Used to filter out reference links that have explicit definitions
342    pub defined_references: HashSet<String>,
343    /// Content hash for change detection
344    pub content_hash: String,
345    /// O(1) anchor lookup: lowercased anchor → heading index
346    /// Includes both auto-generated and custom anchors
347    anchor_to_heading: HashMap<String, usize>,
348    /// O(1) anchor lookup with original case preserved → heading index.
349    /// Used for `ignore_case = false` (markdownlint strict parity). Skipped at
350    /// query time when the lowercase map is sufficient.
351    #[serde(default)]
352    anchor_to_heading_exact: HashMap<String, usize>,
353    /// HTML anchors defined via `<a id="...">` or `<element id="...">` tags.
354    /// Stored lowercase for case-insensitive matching.
355    html_anchors: HashSet<String>,
356    /// HTML anchors with original case preserved.
357    /// Used for `ignore_case = false` (markdownlint strict parity).
358    #[serde(default)]
359    html_anchors_exact: HashSet<String>,
360    /// Attribute anchors defined via { #id } syntax (kramdown/MkDocs attr_list).
361    /// Can appear on any element, not just headings.
362    /// Stored lowercase for case-insensitive matching.
363    attribute_anchors: HashSet<String>,
364    /// Attribute anchors with original case preserved.
365    /// Used for `ignore_case = false` (markdownlint strict parity).
366    #[serde(default)]
367    attribute_anchors_exact: HashSet<String>,
368    /// Rules disabled for the entire file (from inline comments)
369    /// Used by cross-file rules to respect inline disable directives
370    pub file_disabled_rules: HashSet<String>,
371    /// Persistent disable/enable state transitions, sorted by line number.
372    /// Each entry: (line, disabled_rules, enabled_rules). Use binary search to query.
373    pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
374    /// Rules disabled at specific lines via disable-line / disable-next-line
375    pub line_disabled_rules: HashMap<usize, HashSet<String>>,
376}
377
378/// Information about a heading for cross-file lookup
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380pub struct HeadingIndex {
381    /// The heading text (e.g., "Installation Guide")
382    pub text: String,
383    /// Auto-generated anchor (e.g., "installation-guide")
384    pub auto_anchor: String,
385    /// Custom anchor if present (e.g., "install")
386    pub custom_anchor: Option<String>,
387    /// Line number (1-indexed)
388    pub line: usize,
389    /// Whether this is a Setext-style heading (underlined with = or -)
390    #[serde(default)]
391    pub is_setext: bool,
392}
393
394/// Information about a reference link for cross-file analysis
395#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
396pub struct ReferenceLinkIndex {
397    /// The reference ID (the part in `[text][ref]`)
398    pub reference_id: String,
399    /// Line number (1-indexed)
400    pub line: usize,
401    /// Column number (1-indexed)
402    pub column: usize,
403}
404
405/// Where a cross-file link was written.
406///
407/// The index holds one entry per file while configuration resolves per file, so
408/// it records where a link came from rather than whether some configuration
409/// wanted it, and the rule reading it applies its own settings at check time.
410/// That is the only arrangement that can be right when two files resolving
411/// different configurations reference the same target.
412#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
413pub enum LinkOrigin {
414    /// The document body.
415    Body,
416    /// A frontmatter value that reads as a path, such as `link: other.md#a`.
417    /// `field` is the lowercased top-level key owning the value, or `None` where
418    /// no owner is determinable. The two are kept distinct from `Body` so a
419    /// value with no determinable owner is still recognizable as frontmatter.
420    ///
421    /// A Markdown link that happens to be written inside frontmatter
422    /// (`link: [a](other.md#a)`) is `Body`: it is real Markdown link syntax, so
423    /// the extraction that finds body links finds it and rename can rewrite it.
424    FrontMatter { field: Option<String> },
425}
426
427/// Information about a cross-file link for validation
428#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
429pub struct CrossFileLinkIndex {
430    /// The target file path (relative, as it appears in the link)
431    pub target_path: String,
432    /// The fragment/anchor being linked to (without #)
433    pub fragment: String,
434    /// Line number (1-indexed)
435    pub line: usize,
436    /// Column number (1-indexed)
437    pub column: usize,
438    /// Where in the document the link was written.
439    pub origin: LinkOrigin,
440}
441
442/// Every path a cross-file link target can name, in the order to try them.
443///
444/// The target is resolved against the directory holding the link and normalized.
445/// A target carrying no extension is then tried against each extension discovery
446/// treats as Markdown, so a GitHub-style `[x](page#section)` finds `page.md`. A
447/// query string is not part of a file name, so `other.md?raw=true` names
448/// `other.md`.
449///
450/// This is the single answer to "which file does this link mean", shared by the
451/// workspace index lookup and by any caller that has to read the target itself.
452pub fn link_target_candidates(source_file: &Path, target_path: &str) -> Vec<PathBuf> {
453    let target_path = strip_query_and_fragment(target_path);
454
455    let joined = match source_file.parent() {
456        Some(parent) => parent.join(target_path),
457        None => PathBuf::from(target_path),
458    };
459    let base = normalize_relative_path(&joined);
460
461    if base.extension().is_some() {
462        return vec![base];
463    }
464
465    // The exact path first: an extension-less file can be indexed under the name
466    // as written.
467    let mut candidates = Vec::with_capacity(crate::discovery::MARKDOWN_EXTENSIONS.len() + 1);
468    for ext in crate::discovery::MARKDOWN_EXTENSIONS {
469        candidates.push(base.with_extension(ext));
470    }
471    candidates.insert(0, base);
472    candidates
473}
474
475/// Resolve `.` and `..` components without touching the filesystem.
476///
477/// This is how [`link_target_candidates`] spells the paths it returns, so a caller
478/// that has to recognize one of those candidates spells its own path the same way.
479///
480/// A `..` cancels the name before it, and nothing else. A relative path with no
481/// name left to cancel keeps saying `..`, because dropping it would rename the
482/// file: `../notes.md` read from `a.md` would become `notes.md`, a different file
483/// that may well exist. Above a root there is nothing to name, so `/..` is `/`,
484/// which is what the filesystem itself answers.
485pub fn normalize_relative_path(path: &Path) -> PathBuf {
486    let mut components: Vec<std::path::Component<'_>> = Vec::new();
487    for component in path.components() {
488        match component {
489            std::path::Component::CurDir => {}
490            std::path::Component::ParentDir => match components.last() {
491                Some(std::path::Component::Normal(_)) => {
492                    components.pop();
493                }
494                Some(std::path::Component::RootDir) => {}
495                _ => components.push(component),
496            },
497            c => components.push(c),
498        }
499    }
500    components.iter().collect()
501}
502
503impl CrossFileLinkIndex {
504    /// Whether the editor navigates by this link.
505    ///
506    /// Frontmatter values are excluded. They are indexed so MD051 can validate
507    /// them when asked to, but they are not Markdown links: rename computes its
508    /// edits by re-parsing the line as one, so it cannot rewrite a frontmatter
509    /// value, and listing it as a reference would offer a location that a
510    /// subsequent rename silently leaves stale.
511    pub fn is_navigable(&self) -> bool {
512        matches!(self.origin, LinkOrigin::Body)
513    }
514}
515
516/// Information about a vulnerable anchor (heading without custom ID)
517#[derive(Debug, Clone, Serialize, Deserialize)]
518pub struct VulnerableAnchor {
519    /// File path where the heading is located
520    pub file: PathBuf,
521    /// Line number of the heading
522    pub line: usize,
523    /// The heading text
524    pub text: String,
525}
526
527impl WorkspaceIndex {
528    /// Create a new empty workspace index
529    pub fn new() -> Self {
530        Self::default()
531    }
532
533    /// Get the current version (for cache invalidation)
534    pub fn version(&self) -> u64 {
535        self.version
536    }
537
538    /// Get the number of indexed files
539    pub fn file_count(&self) -> usize {
540        self.files.len()
541    }
542
543    /// Check if a file is in the index
544    pub fn contains_file(&self, path: &Path) -> bool {
545        self.files.contains_key(path)
546    }
547
548    /// Get the index data for a specific file
549    pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
550        self.files.get(path)
551    }
552
553    /// Insert or update a file's index data
554    pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
555        self.files.insert(path, index);
556        self.version = self.version.wrapping_add(1);
557    }
558
559    /// Remove a file from the index
560    pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
561        // Clean up reverse deps for this file
562        self.clear_reverse_deps_for(path);
563
564        let result = self.files.remove(path);
565        if result.is_some() {
566            self.version = self.version.wrapping_add(1);
567        }
568        result
569    }
570
571    /// Build a map of all "vulnerable" anchors across the workspace
572    ///
573    /// A vulnerable anchor is an auto-generated anchor for a heading that
574    /// does NOT have a custom anchor defined. These are problematic for
575    /// translated content because the anchor changes when the heading is translated.
576    ///
577    /// Returns: Map from lowercase anchor → Vec of VulnerableAnchor info
578    /// Multiple files can have headings with the same auto-generated anchor,
579    /// so we collect all occurrences.
580    pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
581        let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
582
583        for (file_path, file_index) in &self.files {
584            for heading in &file_index.headings {
585                // Only include headings WITHOUT custom anchors
586                if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
587                    let anchor_key = heading.auto_anchor.to_lowercase();
588                    vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
589                        file: file_path.clone(),
590                        line: heading.line,
591                        text: heading.text.clone(),
592                    });
593                }
594            }
595        }
596
597        vulnerable
598    }
599
600    /// Get all headings across the workspace (for debugging/testing)
601    pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
602        self.files
603            .iter()
604            .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
605    }
606
607    /// Iterate over all files in the index
608    pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
609        self.files.iter().map(|(p, i)| (p.as_path(), i))
610    }
611
612    /// All files in the index, ordered by path.
613    ///
614    /// `files()` iterates the backing `HashMap` in an unspecified order, so
615    /// consumers that emit output (cross-file diagnostics) must use this to
616    /// keep results stable across runs on identical input.
617    pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
618        let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
619        entries.sort_by_key(|(a, _)| *a);
620        entries
621    }
622
623    /// Clear the entire index
624    pub fn clear(&mut self) {
625        self.files.clear();
626        self.reverse_deps.clear();
627        self.version = self.version.wrapping_add(1);
628    }
629
630    /// Update a file's index and maintain reverse dependencies
631    ///
632    /// This method:
633    /// 1. Removes this file as a source (dependent) from all reverse deps
634    /// 2. Inserts the new file index
635    /// 3. Builds new reverse deps from cross_file_links
636    pub fn update_file(&mut self, path: &Path, index: FileIndex) {
637        // Remove this file as a source (dependent) from all target entries
638        // Note: We don't remove it as a target - other files may still link to it
639        self.clear_reverse_deps_as_source(path);
640
641        // Build new reverse deps from cross_file_links
642        for link in &index.cross_file_links {
643            let target = self.resolve_target_path(path, &link.target_path);
644            self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
645        }
646
647        self.files.insert(path.to_path_buf(), index);
648        self.version = self.version.wrapping_add(1);
649    }
650
651    /// Get files that depend on (link to) the given file
652    ///
653    /// Returns a list of file paths that contain links targeting this file.
654    /// Used to re-lint dependent files when a target file changes.
655    pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
656        self.reverse_deps
657            .get(path)
658            .map(|set| set.iter().cloned().collect())
659            .unwrap_or_default()
660    }
661
662    /// Check if a file needs re-indexing based on its content hash
663    ///
664    /// Returns `true` if the file is not in the index or has a different hash.
665    pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
666        self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
667    }
668
669    /// Retain only files that exist in the given set, removing deleted files
670    ///
671    /// This prunes stale entries from the cache for files that no longer exist.
672    /// Returns the number of files removed.
673    pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
674        let before_count = self.files.len();
675
676        // Collect files to remove
677        let to_remove: Vec<PathBuf> = self
678            .files
679            .keys()
680            .filter(|path| !current_files.contains(*path))
681            .cloned()
682            .collect();
683
684        // Remove each file properly (clears reverse deps)
685        for path in &to_remove {
686            self.remove_file(path);
687        }
688
689        before_count - self.files.len()
690    }
691
692    /// Save the workspace index to a cache file
693    ///
694    /// Uses postcard for efficient binary serialization with:
695    /// - Magic header for file type validation
696    /// - Format version for compatibility detection
697    /// - Atomic writes (temp file + rename) to prevent corruption
698    #[cfg(feature = "postcard")]
699    pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
700        use std::fs;
701        use std::io::Write;
702
703        // Ensure cache directory exists
704        fs::create_dir_all(cache_dir)?;
705
706        // Serialize the index data using postcard
707        let encoded = postcard::to_allocvec(self)
708            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
709
710        // Build versioned cache file: [magic][version][data]
711        let mut cache_data = Vec::with_capacity(8 + encoded.len());
712        cache_data.extend_from_slice(CACHE_MAGIC);
713        cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
714        cache_data.extend_from_slice(&encoded);
715
716        // Write atomically: write to temp file then rename. A per-write unique
717        // suffix keeps concurrent writers from clobbering each other's temp file
718        // before the rename. WASI has no process id, so the counter alone carries
719        // uniqueness there (matching `cache.rs`).
720        let final_path = cache_dir.join(CACHE_FILE_NAME);
721        let counter = CACHE_TEMP_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
722        #[cfg(not(target_arch = "wasm32"))]
723        let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{}.{counter}", std::process::id()));
724        #[cfg(target_arch = "wasm32")]
725        let temp_path = cache_dir.join(format!("{CACHE_FILE_NAME}.tmp.{counter}"));
726
727        // Write to temp file
728        {
729            let mut file = fs::File::create(&temp_path)?;
730            file.write_all(&cache_data)?;
731            file.sync_all()?;
732        }
733
734        // Atomic rename
735        fs::rename(&temp_path, &final_path)?;
736
737        log::debug!(
738            "Saved workspace index to cache: {} files, {} bytes (format v{})",
739            self.files.len(),
740            cache_data.len(),
741            CACHE_FORMAT_VERSION
742        );
743
744        Ok(())
745    }
746
747    /// Load the workspace index from a cache file
748    ///
749    /// Returns `None` if:
750    /// - Cache file doesn't exist
751    /// - Magic header doesn't match
752    /// - Format version is incompatible
753    /// - Data is corrupted
754    #[cfg(feature = "postcard")]
755    pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
756        use std::fs;
757
758        let path = cache_dir.join(CACHE_FILE_NAME);
759        let data = fs::read(&path).ok()?;
760
761        // Validate header: need at least 8 bytes for magic + version
762        if data.len() < 8 {
763            log::warn!("Workspace index cache too small, discarding");
764            let _ = fs::remove_file(&path);
765            return None;
766        }
767
768        // Check magic header
769        if &data[0..4] != CACHE_MAGIC {
770            log::warn!("Workspace index cache has invalid magic header, discarding");
771            let _ = fs::remove_file(&path);
772            return None;
773        }
774
775        // Check format version
776        let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
777        if version != CACHE_FORMAT_VERSION {
778            log::info!(
779                "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
780            );
781            let _ = fs::remove_file(&path);
782            return None;
783        }
784
785        // Deserialize the index data using postcard
786        match postcard::from_bytes::<Self>(&data[8..]) {
787            Ok(index) => {
788                log::debug!(
789                    "Loaded workspace index from cache: {} files (format v{})",
790                    index.files.len(),
791                    version
792                );
793                Some(index)
794            }
795            Err(e) => {
796                log::warn!("Failed to deserialize workspace index cache: {e}");
797                let _ = fs::remove_file(&path);
798                None
799            }
800        }
801    }
802
803    /// Remove a file as a source from all reverse dependency entries
804    ///
805    /// This removes the file from being listed as a dependent in all target entries.
806    /// Used when updating a file (we need to remove old outgoing links before adding new ones).
807    fn clear_reverse_deps_as_source(&mut self, path: &Path) {
808        // Remove `path` as a dependent only from the targets it actually links
809        // to, discovered from its current index, instead of scanning every
810        // entry in reverse_deps. Both callers (update_file, clear_reverse_deps_for)
811        // run this before the file's entry is replaced/removed, so self.files[path]
812        // still holds the links that produced these reverse-dep entries; resolving
813        // them the same way reverses exactly the prior insertions.
814        let targets: Vec<PathBuf> = match self.files.get(path) {
815            Some(index) => index
816                .cross_file_links
817                .iter()
818                .map(|link| self.resolve_target_path(path, &link.target_path))
819                .collect(),
820            None => return,
821        };
822        for target in targets {
823            if let Some(deps) = self.reverse_deps.get_mut(&target) {
824                deps.remove(path);
825                if deps.is_empty() {
826                    self.reverse_deps.remove(&target);
827                }
828            }
829        }
830    }
831
832    /// Remove a file completely from reverse dependency tracking
833    ///
834    /// Removes the file as both a source (dependent) and as a target.
835    /// Used when deleting a file from the index.
836    fn clear_reverse_deps_for(&mut self, path: &Path) {
837        // Remove as source (dependent)
838        self.clear_reverse_deps_as_source(path);
839
840        // Also remove as target
841        self.reverse_deps.remove(path);
842    }
843
844    /// Resolve a relative path from a source file to an absolute target path
845    ///
846    /// This keys the reverse dependency graph, which is looked up by the path of
847    /// a file that changed, so it has to answer with a file name - the same
848    /// question [`link_target_file`] answers for every other consumer.
849    fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
850        let source_dir = source_file.parent().unwrap_or(Path::new(""));
851        link_target_file(source_dir, relative_target)
852    }
853}
854
855impl FileIndex {
856    /// Create a new empty file index
857    pub fn new() -> Self {
858        Self::default()
859    }
860
861    /// Create a file index with the given content hash
862    pub fn with_hash(content_hash: String) -> Self {
863        Self {
864            content_hash,
865            ..Default::default()
866        }
867    }
868
869    /// Whether the data extracted from the file differs, ignoring the hash of
870    /// the text it came from.
871    ///
872    /// This answers whether re-indexing a file can have changed a cross-file
873    /// result, its own or that of a file linking to it. Every cross-file check
874    /// reads the entry rather than the document, so two entries that agree on
875    /// everything compared here produce the same answers however much the text
876    /// between them changed. The hash is excluded precisely because it is a
877    /// fingerprint of that text: it moves on every keystroke while the links
878    /// and anchors a check reads stay put.
879    ///
880    /// Entries carry the positions their headings and links were written at, so
881    /// an edit that adds or removes a line does differ even when it changes no
882    /// anchor. That is the conservative direction: it costs a re-lint of the
883    /// open files linking here, and never withholds one.
884    pub fn extracted_data_differs(&self, other: &Self) -> bool {
885        // Destructured so that adding a field to the index is a compile error
886        // here rather than a silent omission, which would make a real change
887        // look like no change and leave a stale diagnostic on screen.
888        let Self {
889            headings,
890            reference_links,
891            cross_file_links,
892            root_relative_links,
893            defined_references,
894            content_hash: _,
895            anchor_to_heading,
896            anchor_to_heading_exact,
897            html_anchors,
898            html_anchors_exact,
899            attribute_anchors,
900            attribute_anchors_exact,
901            file_disabled_rules,
902            persistent_transitions,
903            line_disabled_rules,
904        } = self;
905
906        headings != &other.headings
907            || reference_links != &other.reference_links
908            || cross_file_links != &other.cross_file_links
909            || root_relative_links != &other.root_relative_links
910            || defined_references != &other.defined_references
911            || anchor_to_heading != &other.anchor_to_heading
912            || anchor_to_heading_exact != &other.anchor_to_heading_exact
913            || html_anchors != &other.html_anchors
914            || html_anchors_exact != &other.html_anchors_exact
915            || attribute_anchors != &other.attribute_anchors
916            || attribute_anchors_exact != &other.attribute_anchors_exact
917            || file_disabled_rules != &other.file_disabled_rules
918            || persistent_transitions != &other.persistent_transitions
919            || line_disabled_rules != &other.line_disabled_rules
920    }
921
922    /// Add a heading to the index
923    ///
924    /// Also updates the anchor lookup maps for O(1) anchor queries. Both
925    /// lowercased (for `ignore_case = true`) and case-preserving (for
926    /// `ignore_case = false`) maps are populated.
927    pub fn add_heading(&mut self, heading: HeadingIndex) {
928        let index = self.headings.len();
929
930        // Auto-generated anchor — slugs are already lowercase, but we still
931        // populate both maps so query-time dispatch is uniform.
932        self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
933        self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
934
935        // Custom anchor preserves original case as written by the author.
936        if let Some(ref custom) = heading.custom_anchor {
937            self.anchor_to_heading.insert(custom.to_lowercase(), index);
938            self.anchor_to_heading_exact.insert(custom.clone(), index);
939        }
940
941        self.headings.push(heading);
942    }
943
944    /// Add an alternative anchor that resolves to an existing heading.
945    /// Used for platform-specific anchor conventions (e.g., Python-Markdown `_N` dedup).
946    pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
947        if heading_index < self.headings.len() {
948            self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
949            self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
950        }
951    }
952
953    /// Check if an anchor exists in this file (O(1) lookup)
954    ///
955    /// Returns true if the anchor matches any of:
956    /// - Auto-generated heading anchors
957    /// - Custom heading anchors (from {#id} syntax on headings)
958    /// - HTML anchors (from `<a id="...">` or `<element id="...">`)
959    /// - Attribute anchors (from { #id } syntax on non-heading elements)
960    ///
961    /// Matching is case-insensitive. URL-encoded anchors (e.g., CJK characters
962    /// like `%E6%97%A5%E6%9C%AC%E8%AA%9E` for `日本語`) are decoded before matching.
963    pub fn has_anchor(&self, anchor: &str) -> bool {
964        self.has_anchor_with_case(anchor, true)
965    }
966
967    /// Check if an anchor exists in this file, with explicit case sensitivity.
968    ///
969    /// When `ignore_case` is `true`, behaves identically to [`has_anchor`] —
970    /// inputs are lowercased and matched against the lowercase storage.
971    /// When `false`, the input is compared as-is against parallel
972    /// case-preserving storage, matching markdownlint's strict behavior for
973    /// generated heading slugs, custom heading IDs, HTML anchors, and
974    /// attribute anchors.
975    pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
976        if self.lookup_anchor(anchor, ignore_case) {
977            return true;
978        }
979
980        // Slow path: if anchor contains percent-encoding, try decoded version
981        if anchor.contains('%') {
982            let decoded = url_decode(anchor);
983            if decoded != anchor {
984                return self.lookup_anchor(&decoded, ignore_case);
985            }
986        }
987
988        false
989    }
990
991    /// Direct anchor lookup, dispatching to the lowercase or exact-case
992    /// storage based on `ignore_case`.
993    fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
994        if ignore_case {
995            let lower = anchor.to_lowercase();
996            self.anchor_to_heading.contains_key(&lower)
997                || self.html_anchors.contains(&lower)
998                || self.attribute_anchors.contains(&lower)
999        } else {
1000            self.anchor_to_heading_exact.contains_key(anchor)
1001                || self.html_anchors_exact.contains(anchor)
1002                || self.attribute_anchors_exact.contains(anchor)
1003        }
1004    }
1005
1006    /// Add an HTML anchor (from `<a id="...">` or `<element id="...">` tags).
1007    /// Populates both lowercase (case-insensitive) and case-preserving sets.
1008    pub fn add_html_anchor(&mut self, anchor: &str) {
1009        if !anchor.is_empty() {
1010            self.html_anchors.insert(anchor.to_lowercase());
1011            self.html_anchors_exact.insert(anchor.to_string());
1012        }
1013    }
1014
1015    /// Add an attribute anchor (from { #id } syntax on non-heading elements).
1016    /// Populates both lowercase (case-insensitive) and case-preserving sets.
1017    pub fn add_attribute_anchor(&mut self, anchor: &str) {
1018        if !anchor.is_empty() {
1019            self.attribute_anchors.insert(anchor.to_lowercase());
1020            self.attribute_anchors_exact.insert(anchor.to_string());
1021        }
1022    }
1023
1024    /// Get the heading index for an anchor (O(1) lookup)
1025    ///
1026    /// Returns the index into `self.headings` if found.
1027    pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
1028        self.anchor_to_heading
1029            .get(&anchor.to_lowercase())
1030            .and_then(|&idx| self.headings.get(idx))
1031    }
1032
1033    /// Add a reference link to the index
1034    pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
1035        self.reference_links.push(link);
1036    }
1037
1038    /// Check if a rule is disabled at a specific line
1039    ///
1040    /// Used by cross-file rules to respect inline disable directives.
1041    /// Checks both file-wide disables and line-specific disables.
1042    pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
1043        // Check file-wide disables (highest priority)
1044        if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
1045            return true;
1046        }
1047
1048        // Check line-specific disables (disable-line / disable-next-line)
1049        if let Some(rules) = self.line_disabled_rules.get(&line)
1050            && (rules.contains("*") || rules.contains(rule_name))
1051        {
1052            return true;
1053        }
1054
1055        // Check persistent disable/enable transitions via binary search
1056        if !self.persistent_transitions.is_empty() {
1057            let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
1058                Ok(i) => Some(i),
1059                Err(i) => {
1060                    if i > 0 {
1061                        Some(i - 1)
1062                    } else {
1063                        None
1064                    }
1065                }
1066            };
1067            if let Some(i) = idx {
1068                let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
1069                if disabled.contains("*") {
1070                    return !enabled.contains(rule_name);
1071                }
1072                return disabled.contains(rule_name);
1073            }
1074        }
1075
1076        false
1077    }
1078
1079    /// Add a cross-file link to the index, keyed on the file it names, the
1080    /// fragment it asks for, and the line it sits on.
1081    ///
1082    /// Several rules contribute the same link and spell it differently: MD051
1083    /// records the destination as written and starts at the link, MD057 records
1084    /// the file that destination names and starts at the URL. Neither the string
1085    /// nor the column can identify a link, so the file it names does - comparing
1086    /// the raw strings let `page.md?raw=true` in as a second entry alongside
1087    /// `page.md`, and MD051, which reports every entry, then reported the same
1088    /// broken fragment twice.
1089    ///
1090    /// One key per line is what the index has always recorded, so two links on
1091    /// one line asking the same file for the same fragment are one entry
1092    /// however each of them spells the destination.
1093    pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
1094        let existing = self.cross_file_links.iter_mut().find(|existing| {
1095            existing.fragment == link.fragment
1096                && existing.line == link.line
1097                && strip_query_and_fragment(&existing.target_path) == strip_query_and_fragment(&link.target_path)
1098        });
1099        match existing {
1100            // A message quotes the destination back, so the spelling that kept the
1101            // query string is the one to keep, whichever rule recorded it first.
1102            Some(existing) => {
1103                if !existing.target_path.contains('?') && link.target_path.contains('?') {
1104                    *existing = link;
1105                }
1106            }
1107            None => self.cross_file_links.push(link),
1108        }
1109    }
1110
1111    /// Add a root-relative link to the index (deduplicates by target_path, fragment, line)
1112    pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
1113        let is_duplicate = self.root_relative_links.iter().any(|existing| {
1114            existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
1115        });
1116        if !is_duplicate {
1117            self.root_relative_links.push(link);
1118        }
1119    }
1120
1121    /// Add a defined reference ID (e.g., from `[ref]: url`)
1122    pub fn add_defined_reference(&mut self, ref_id: String) {
1123        self.defined_references.insert(ref_id);
1124    }
1125
1126    /// Check if a reference ID has an explicit definition
1127    pub fn has_defined_reference(&self, ref_id: &str) -> bool {
1128        self.defined_references.contains(ref_id)
1129    }
1130
1131    /// Check if the content hash matches
1132    pub fn hash_matches(&self, hash: &str) -> bool {
1133        self.content_hash == hash
1134    }
1135
1136    /// Get the number of headings
1137    pub fn heading_count(&self) -> usize {
1138        self.headings.len()
1139    }
1140
1141    /// Get the number of reference links
1142    pub fn reference_link_count(&self) -> usize {
1143        self.reference_links.len()
1144    }
1145}
1146
1147#[cfg(test)]
1148mod tests {
1149    use super::*;
1150
1151    #[test]
1152    fn test_workspace_index_basic() {
1153        let mut index = WorkspaceIndex::new();
1154        assert_eq!(index.file_count(), 0);
1155        assert_eq!(index.version(), 0);
1156
1157        let mut file_index = FileIndex::with_hash("abc123".to_string());
1158        file_index.add_heading(HeadingIndex {
1159            text: "Installation".to_string(),
1160            auto_anchor: "installation".to_string(),
1161            custom_anchor: None,
1162            line: 1,
1163            is_setext: false,
1164        });
1165
1166        index.insert_file(PathBuf::from("docs/install.md"), file_index);
1167        assert_eq!(index.file_count(), 1);
1168        assert_eq!(index.version(), 1);
1169
1170        assert!(index.contains_file(Path::new("docs/install.md")));
1171        assert!(!index.contains_file(Path::new("docs/other.md")));
1172    }
1173
1174    #[test]
1175    fn test_vulnerable_anchors() {
1176        let mut index = WorkspaceIndex::new();
1177
1178        // File 1: heading without custom anchor (vulnerable)
1179        let mut file1 = FileIndex::new();
1180        file1.add_heading(HeadingIndex {
1181            text: "Getting Started".to_string(),
1182            auto_anchor: "getting-started".to_string(),
1183            custom_anchor: None,
1184            line: 1,
1185            is_setext: false,
1186        });
1187        index.insert_file(PathBuf::from("docs/guide.md"), file1);
1188
1189        // File 2: heading with custom anchor (not vulnerable)
1190        let mut file2 = FileIndex::new();
1191        file2.add_heading(HeadingIndex {
1192            text: "Installation".to_string(),
1193            auto_anchor: "installation".to_string(),
1194            custom_anchor: Some("install".to_string()),
1195            line: 1,
1196            is_setext: false,
1197        });
1198        index.insert_file(PathBuf::from("docs/install.md"), file2);
1199
1200        let vulnerable = index.get_vulnerable_anchors();
1201        assert_eq!(vulnerable.len(), 1);
1202        assert!(vulnerable.contains_key("getting-started"));
1203        assert!(!vulnerable.contains_key("installation"));
1204
1205        let anchors = vulnerable.get("getting-started").unwrap();
1206        assert_eq!(anchors.len(), 1);
1207        assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1208        assert_eq!(anchors[0].text, "Getting Started");
1209    }
1210
1211    #[test]
1212    fn test_vulnerable_anchors_multiple_files_same_anchor() {
1213        // Multiple files can have headings with the same auto-generated anchor
1214        // get_vulnerable_anchors() should collect all of them
1215        let mut index = WorkspaceIndex::new();
1216
1217        // File 1: has "Installation" heading (vulnerable)
1218        let mut file1 = FileIndex::new();
1219        file1.add_heading(HeadingIndex {
1220            text: "Installation".to_string(),
1221            auto_anchor: "installation".to_string(),
1222            custom_anchor: None,
1223            line: 1,
1224            is_setext: false,
1225        });
1226        index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1227
1228        // File 2: also has "Installation" heading with same anchor (vulnerable)
1229        let mut file2 = FileIndex::new();
1230        file2.add_heading(HeadingIndex {
1231            text: "Installation".to_string(),
1232            auto_anchor: "installation".to_string(),
1233            custom_anchor: None,
1234            line: 5,
1235            is_setext: false,
1236        });
1237        index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1238
1239        // File 3: has "Installation" but WITH custom anchor (not vulnerable)
1240        let mut file3 = FileIndex::new();
1241        file3.add_heading(HeadingIndex {
1242            text: "Installation".to_string(),
1243            auto_anchor: "installation".to_string(),
1244            custom_anchor: Some("install".to_string()),
1245            line: 10,
1246            is_setext: false,
1247        });
1248        index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1249
1250        let vulnerable = index.get_vulnerable_anchors();
1251        assert_eq!(vulnerable.len(), 1); // One unique anchor
1252        assert!(vulnerable.contains_key("installation"));
1253
1254        let anchors = vulnerable.get("installation").unwrap();
1255        // Should have 2 entries (en and fr), NOT 3 (de has custom anchor)
1256        assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1257
1258        // Verify both files are represented
1259        let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1260        assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1261        assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1262    }
1263
1264    #[test]
1265    fn test_file_index_hash() {
1266        let index = FileIndex::with_hash("hash123".to_string());
1267        assert!(index.hash_matches("hash123"));
1268        assert!(!index.hash_matches("other"));
1269    }
1270
1271    #[test]
1272    fn test_version_increment() {
1273        let mut index = WorkspaceIndex::new();
1274        assert_eq!(index.version(), 0);
1275
1276        index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1277        assert_eq!(index.version(), 1);
1278
1279        index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1280        assert_eq!(index.version(), 2);
1281
1282        index.remove_file(Path::new("a.md"));
1283        assert_eq!(index.version(), 3);
1284
1285        // Removing non-existent file doesn't increment
1286        index.remove_file(Path::new("nonexistent.md"));
1287        assert_eq!(index.version(), 3);
1288    }
1289
1290    #[test]
1291    fn test_files_sorted_is_path_ordered() {
1292        let mut index = WorkspaceIndex::new();
1293        // Insert in a deliberately non-sorted order.
1294        for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1295            index.update_file(Path::new(name), FileIndex::new());
1296        }
1297
1298        let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1299        assert_eq!(
1300            paths,
1301            vec![
1302                Path::new("docs/apple.md"),
1303                Path::new("docs/mango.md"),
1304                Path::new("docs/zebra.md"),
1305            ],
1306            "files_sorted() must return entries ordered by path"
1307        );
1308    }
1309
1310    /// Two rules record the same link, one keeping the query string and one
1311    /// keeping only the file it names. That is one link, and the spelling kept
1312    /// is the destination as written whichever rule got there first - a message
1313    /// quotes it back, so the answer must not depend on rule order.
1314    #[test]
1315    fn test_add_cross_file_link_keeps_the_destination_as_written() {
1316        let as_written = CrossFileLinkIndex {
1317            target_path: "other.md?raw=true".to_string(),
1318            fragment: "missing".to_string(),
1319            line: 3,
1320            column: 1,
1321            origin: LinkOrigin::Body,
1322        };
1323        let file_named = CrossFileLinkIndex {
1324            target_path: "other.md".to_string(),
1325            fragment: "missing".to_string(),
1326            line: 3,
1327            column: 9,
1328            origin: LinkOrigin::Body,
1329        };
1330
1331        for (first, second) in [
1332            (as_written.clone(), file_named.clone()),
1333            (file_named.clone(), as_written.clone()),
1334        ] {
1335            let mut index = FileIndex::new();
1336            index.add_cross_file_link(first);
1337            index.add_cross_file_link(second);
1338
1339            assert_eq!(
1340                index.cross_file_links.len(),
1341                1,
1342                "one link is one entry, got: {:?}",
1343                index.cross_file_links
1344            );
1345            assert_eq!(index.cross_file_links[0].target_path, "other.md?raw=true");
1346        }
1347    }
1348
1349    /// Links to two different files are two entries, so the deduplication above
1350    /// cannot swallow a second target.
1351    #[test]
1352    fn test_add_cross_file_link_keeps_distinct_targets() {
1353        let mut index = FileIndex::new();
1354        for target in ["one.md", "two.md"] {
1355            index.add_cross_file_link(CrossFileLinkIndex {
1356                target_path: target.to_string(),
1357                fragment: "missing".to_string(),
1358                line: 3,
1359                column: 1,
1360                origin: LinkOrigin::Body,
1361            });
1362        }
1363        assert_eq!(index.cross_file_links.len(), 2);
1364    }
1365
1366    /// Two links on one line asking the same file for the same fragment are one
1367    /// entry, which is what the index has always recorded for two identically
1368    /// spelled destinations. Differing query strings do not make them two links,
1369    /// because a query string is not part of a file name.
1370    ///
1371    /// A different fragment, or the same link on another line, stays its own
1372    /// entry - so this is a boundary, not a blanket collapse to one finding.
1373    #[test]
1374    fn test_add_cross_file_link_collapses_one_line_asking_one_file_once() {
1375        let link = |target: &str, fragment: &str, line: usize| CrossFileLinkIndex {
1376            target_path: target.to_string(),
1377            fragment: fragment.to_string(),
1378            line,
1379            column: 1,
1380            origin: LinkOrigin::Body,
1381        };
1382
1383        let mut index = FileIndex::new();
1384        index.add_cross_file_link(link("target.md?raw=true", "missing", 3));
1385        index.add_cross_file_link(link("target.md?plain=1", "missing", 3));
1386        assert_eq!(
1387            index.cross_file_links.len(),
1388            1,
1389            "one file, one fragment, one line is one entry, got: {:?}",
1390            index.cross_file_links
1391        );
1392
1393        index.add_cross_file_link(link("target.md", "other", 3));
1394        index.add_cross_file_link(link("target.md", "missing", 4));
1395        assert_eq!(index.cross_file_links.len(), 3);
1396    }
1397
1398    /// A link is a dependency on the file it names, so editing `b.md` re-lints
1399    /// the source whichever way that source spelled the destination. The query
1400    /// string is the case that gets this wrong: it is not part of a file name,
1401    /// nothing ever creates a file called `b.md?raw=true`, and a reverse
1402    /// dependency filed under that name is one no editor will ever look up.
1403    #[test]
1404    fn test_reverse_deps_ignore_a_query_string_on_the_destination() {
1405        let mut index = WorkspaceIndex::new();
1406
1407        let mut file_a = FileIndex::new();
1408        file_a.add_cross_file_link(CrossFileLinkIndex {
1409            target_path: "b.md?raw=true".to_string(),
1410            fragment: "section".to_string(),
1411            line: 10,
1412            column: 5,
1413            origin: LinkOrigin::Body,
1414        });
1415        index.update_file(Path::new("docs/a.md"), file_a);
1416
1417        assert_eq!(
1418            index.get_dependents(Path::new("docs/b.md")),
1419            vec![PathBuf::from("docs/a.md")],
1420            "editing docs/b.md must re-lint the file linking to it"
1421        );
1422
1423        // And the source stops depending on it once the link is gone, so the
1424        // stripped key is cleared by the same route it was created.
1425        index.update_file(Path::new("docs/a.md"), FileIndex::new());
1426        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1427    }
1428
1429    #[test]
1430    fn test_reverse_deps_basic() {
1431        let mut index = WorkspaceIndex::new();
1432
1433        // File A links to file B
1434        let mut file_a = FileIndex::new();
1435        file_a.add_cross_file_link(CrossFileLinkIndex {
1436            target_path: "b.md".to_string(),
1437            fragment: "section".to_string(),
1438            line: 10,
1439            column: 5,
1440            origin: LinkOrigin::Body,
1441        });
1442        index.update_file(Path::new("docs/a.md"), file_a);
1443
1444        // Check that B has A as a dependent
1445        let dependents = index.get_dependents(Path::new("docs/b.md"));
1446        assert_eq!(dependents.len(), 1);
1447        assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1448
1449        // A has no dependents
1450        let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1451        assert!(a_dependents.is_empty());
1452    }
1453
1454    #[test]
1455    fn test_reverse_deps_multiple() {
1456        let mut index = WorkspaceIndex::new();
1457
1458        // Files A and C both link to B
1459        let mut file_a = FileIndex::new();
1460        file_a.add_cross_file_link(CrossFileLinkIndex {
1461            target_path: "../b.md".to_string(),
1462            fragment: "".to_string(),
1463            line: 1,
1464            column: 1,
1465            origin: LinkOrigin::Body,
1466        });
1467        index.update_file(Path::new("docs/sub/a.md"), file_a);
1468
1469        let mut file_c = FileIndex::new();
1470        file_c.add_cross_file_link(CrossFileLinkIndex {
1471            target_path: "b.md".to_string(),
1472            fragment: "".to_string(),
1473            line: 1,
1474            column: 1,
1475            origin: LinkOrigin::Body,
1476        });
1477        index.update_file(Path::new("docs/c.md"), file_c);
1478
1479        // B should have both A and C as dependents
1480        let dependents = index.get_dependents(Path::new("docs/b.md"));
1481        assert_eq!(dependents.len(), 2);
1482        assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1483        assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1484    }
1485
1486    #[test]
1487    fn test_reverse_deps_update_clears_old() {
1488        let mut index = WorkspaceIndex::new();
1489
1490        // File A initially links to B
1491        let mut file_a = FileIndex::new();
1492        file_a.add_cross_file_link(CrossFileLinkIndex {
1493            target_path: "b.md".to_string(),
1494            fragment: "".to_string(),
1495            line: 1,
1496            column: 1,
1497            origin: LinkOrigin::Body,
1498        });
1499        index.update_file(Path::new("docs/a.md"), file_a);
1500
1501        // Verify B has A as dependent
1502        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1503
1504        // Update A to link to C instead of B
1505        let mut file_a_updated = FileIndex::new();
1506        file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1507            target_path: "c.md".to_string(),
1508            fragment: "".to_string(),
1509            line: 1,
1510            column: 1,
1511            origin: LinkOrigin::Body,
1512        });
1513        index.update_file(Path::new("docs/a.md"), file_a_updated);
1514
1515        // B should no longer have A as dependent
1516        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1517
1518        // C should now have A as dependent
1519        let c_deps = index.get_dependents(Path::new("docs/c.md"));
1520        assert_eq!(c_deps.len(), 1);
1521        assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1522    }
1523
1524    #[test]
1525    fn test_reverse_deps_remove_file() {
1526        let mut index = WorkspaceIndex::new();
1527
1528        // File A links to B
1529        let mut file_a = FileIndex::new();
1530        file_a.add_cross_file_link(CrossFileLinkIndex {
1531            target_path: "b.md".to_string(),
1532            fragment: "".to_string(),
1533            line: 1,
1534            column: 1,
1535            origin: LinkOrigin::Body,
1536        });
1537        index.update_file(Path::new("docs/a.md"), file_a);
1538
1539        // Verify B has A as dependent
1540        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1541
1542        // Remove file A
1543        index.remove_file(Path::new("docs/a.md"));
1544
1545        // B should no longer have any dependents
1546        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1547    }
1548
1549    #[test]
1550    fn test_normalize_path() {
1551        // Test .. handling
1552        let path = Path::new("docs/sub/../other.md");
1553        let normalized = normalize_relative_path(path);
1554        assert_eq!(normalized, PathBuf::from("docs/other.md"));
1555
1556        // Test . handling
1557        let path2 = Path::new("docs/./other.md");
1558        let normalized2 = normalize_relative_path(path2);
1559        assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1560
1561        // Test multiple ..
1562        let path3 = Path::new("a/b/c/../../d.md");
1563        let normalized3 = normalize_relative_path(path3);
1564        assert_eq!(normalized3, PathBuf::from("a/d.md"));
1565    }
1566
1567    /// A `..` with no name in front of it is the path saying it leaves the
1568    /// directory it started in. Dropping it renames the file - `../notes.md`
1569    /// would become `notes.md`, a sibling that may well exist and is not the file
1570    /// the link names - so it survives normalization.
1571    #[test]
1572    fn normalize_keeps_a_traversal_that_leaves_its_own_root() {
1573        assert_eq!(
1574            normalize_relative_path(Path::new("../notes.md")),
1575            PathBuf::from("../notes.md")
1576        );
1577        assert_eq!(
1578            normalize_relative_path(Path::new("docs/../../notes.md")),
1579            PathBuf::from("../notes.md")
1580        );
1581        assert_eq!(
1582            normalize_relative_path(Path::new("../../a/./b/../notes.md")),
1583            PathBuf::from("../../a/notes.md")
1584        );
1585    }
1586
1587    /// There is nothing above a root to name, which is what the filesystem
1588    /// itself answers for `/..`.
1589    #[test]
1590    fn normalize_stops_a_traversal_at_a_root() {
1591        let root = if cfg!(windows) { "C:\\" } else { "/" };
1592        assert_eq!(
1593            normalize_relative_path(&Path::new(root).join("..").join("notes.md")),
1594            Path::new(root).join("notes.md")
1595        );
1596    }
1597
1598    #[test]
1599    fn test_clear_clears_reverse_deps() {
1600        let mut index = WorkspaceIndex::new();
1601
1602        // File A links to B
1603        let mut file_a = FileIndex::new();
1604        file_a.add_cross_file_link(CrossFileLinkIndex {
1605            target_path: "b.md".to_string(),
1606            fragment: "".to_string(),
1607            line: 1,
1608            column: 1,
1609            origin: LinkOrigin::Body,
1610        });
1611        index.update_file(Path::new("docs/a.md"), file_a);
1612
1613        // Verify B has A as dependent
1614        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1615
1616        // Clear the index
1617        index.clear();
1618
1619        // Both files and reverse deps should be cleared
1620        assert_eq!(index.file_count(), 0);
1621        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1622    }
1623
1624    #[test]
1625    fn test_is_file_stale() {
1626        let mut index = WorkspaceIndex::new();
1627
1628        // Non-existent file is always stale
1629        assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1630
1631        // Add a file with known hash
1632        let file_index = FileIndex::with_hash("hash123".to_string());
1633        index.insert_file(PathBuf::from("docs/test.md"), file_index);
1634
1635        // Same hash means not stale
1636        assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1637
1638        // Different hash means stale
1639        assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1640    }
1641
1642    #[cfg(feature = "native")]
1643    #[test]
1644    fn test_cache_roundtrip() {
1645        use std::fs;
1646
1647        // Create a temp directory
1648        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1649        let _ = fs::remove_dir_all(&temp_dir);
1650        fs::create_dir_all(&temp_dir).unwrap();
1651
1652        // Create an index with some data
1653        let mut index = WorkspaceIndex::new();
1654
1655        let mut file1 = FileIndex::with_hash("abc123".to_string());
1656        file1.add_heading(HeadingIndex {
1657            text: "Test Heading".to_string(),
1658            auto_anchor: "test-heading".to_string(),
1659            custom_anchor: Some("test".to_string()),
1660            line: 1,
1661            is_setext: false,
1662        });
1663        file1.add_cross_file_link(CrossFileLinkIndex {
1664            target_path: "./other.md".to_string(),
1665            fragment: "section".to_string(),
1666            line: 5,
1667            column: 3,
1668            origin: LinkOrigin::Body,
1669        });
1670        index.update_file(Path::new("docs/file1.md"), file1);
1671
1672        let mut file2 = FileIndex::with_hash("def456".to_string());
1673        file2.add_heading(HeadingIndex {
1674            text: "Another Heading".to_string(),
1675            auto_anchor: "another-heading".to_string(),
1676            custom_anchor: None,
1677            line: 1,
1678            is_setext: false,
1679        });
1680        index.update_file(Path::new("docs/other.md"), file2);
1681
1682        // Save to cache
1683        index.save_to_cache(&temp_dir).expect("Failed to save cache");
1684
1685        // Verify cache file exists
1686        assert!(temp_dir.join("workspace_index.bin").exists());
1687
1688        // Load from cache
1689        let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1690
1691        // Verify data matches
1692        assert_eq!(loaded.file_count(), 2);
1693        assert!(loaded.contains_file(Path::new("docs/file1.md")));
1694        assert!(loaded.contains_file(Path::new("docs/other.md")));
1695
1696        // Check file1 details
1697        let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1698        assert_eq!(file1_loaded.content_hash, "abc123");
1699        assert_eq!(file1_loaded.headings.len(), 1);
1700        assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1701        assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1702        assert_eq!(file1_loaded.cross_file_links.len(), 1);
1703        assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1704
1705        // Check reverse deps were serialized correctly
1706        let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1707        assert_eq!(dependents.len(), 1);
1708        assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1709
1710        // Clean up
1711        let _ = fs::remove_dir_all(&temp_dir);
1712    }
1713
1714    #[cfg(feature = "native")]
1715    #[test]
1716    fn test_cache_missing_file() {
1717        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1718        let _ = std::fs::remove_dir_all(&temp_dir);
1719
1720        // Should return None for non-existent cache
1721        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1722        assert!(result.is_none());
1723    }
1724
1725    #[cfg(feature = "native")]
1726    #[test]
1727    fn test_cache_corrupted_file() {
1728        use std::fs;
1729
1730        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1731        let _ = fs::remove_dir_all(&temp_dir);
1732        fs::create_dir_all(&temp_dir).unwrap();
1733
1734        // Write corrupted data (too small for header)
1735        fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1736
1737        // Should return None for corrupted cache (and remove the file)
1738        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1739        assert!(result.is_none());
1740
1741        // Corrupted file should be removed
1742        assert!(!temp_dir.join("workspace_index.bin").exists());
1743
1744        // Clean up
1745        let _ = fs::remove_dir_all(&temp_dir);
1746    }
1747
1748    #[cfg(feature = "native")]
1749    #[test]
1750    fn test_cache_invalid_magic() {
1751        use std::fs;
1752
1753        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1754        let _ = fs::remove_dir_all(&temp_dir);
1755        fs::create_dir_all(&temp_dir).unwrap();
1756
1757        // Write data with wrong magic header
1758        let mut data = Vec::new();
1759        data.extend_from_slice(b"XXXX"); // Wrong magic
1760        data.extend_from_slice(&1u32.to_le_bytes()); // Version 1
1761        data.extend_from_slice(&[0; 100]); // Some garbage data
1762        fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1763
1764        // Should return None for invalid magic
1765        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1766        assert!(result.is_none());
1767
1768        // File should be removed
1769        assert!(!temp_dir.join("workspace_index.bin").exists());
1770
1771        // Clean up
1772        let _ = fs::remove_dir_all(&temp_dir);
1773    }
1774
1775    #[cfg(feature = "native")]
1776    #[test]
1777    fn test_cache_version_mismatch() {
1778        use std::fs;
1779
1780        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1781        let _ = fs::remove_dir_all(&temp_dir);
1782        fs::create_dir_all(&temp_dir).unwrap();
1783
1784        // Write data with correct magic but wrong version
1785        let mut data = Vec::new();
1786        data.extend_from_slice(b"RWSI"); // Correct magic
1787        data.extend_from_slice(&999u32.to_le_bytes()); // Future version
1788        data.extend_from_slice(&[0; 100]); // Some garbage data
1789        fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1790
1791        // Should return None for version mismatch
1792        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1793        assert!(result.is_none());
1794
1795        // File should be removed to trigger rebuild
1796        assert!(!temp_dir.join("workspace_index.bin").exists());
1797
1798        // Clean up
1799        let _ = fs::remove_dir_all(&temp_dir);
1800    }
1801
1802    #[cfg(feature = "native")]
1803    #[test]
1804    fn test_cache_atomic_write() {
1805        use std::fs;
1806
1807        // Test that atomic writes work (no temp files left behind)
1808        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1809        let _ = fs::remove_dir_all(&temp_dir);
1810        fs::create_dir_all(&temp_dir).unwrap();
1811
1812        let index = WorkspaceIndex::new();
1813        index.save_to_cache(&temp_dir).expect("Failed to save");
1814
1815        // Only the final cache file should exist, no temp files
1816        let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1817        assert_eq!(entries.len(), 1);
1818        assert!(temp_dir.join("workspace_index.bin").exists());
1819
1820        // Clean up
1821        let _ = fs::remove_dir_all(&temp_dir);
1822    }
1823
1824    #[test]
1825    fn test_has_anchor_auto_generated() {
1826        let mut file_index = FileIndex::new();
1827        file_index.add_heading(HeadingIndex {
1828            text: "Installation Guide".to_string(),
1829            auto_anchor: "installation-guide".to_string(),
1830            custom_anchor: None,
1831            line: 1,
1832            is_setext: false,
1833        });
1834
1835        // Should find by auto-generated anchor
1836        assert!(file_index.has_anchor("installation-guide"));
1837
1838        // Case-insensitive matching
1839        assert!(file_index.has_anchor("Installation-Guide"));
1840        assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1841
1842        // Should not find non-existent anchor
1843        assert!(!file_index.has_anchor("nonexistent"));
1844    }
1845
1846    #[test]
1847    fn test_has_anchor_custom() {
1848        let mut file_index = FileIndex::new();
1849        file_index.add_heading(HeadingIndex {
1850            text: "Installation Guide".to_string(),
1851            auto_anchor: "installation-guide".to_string(),
1852            custom_anchor: Some("install".to_string()),
1853            line: 1,
1854            is_setext: false,
1855        });
1856
1857        // Should find by auto-generated anchor
1858        assert!(file_index.has_anchor("installation-guide"));
1859
1860        // Should also find by custom anchor
1861        assert!(file_index.has_anchor("install"));
1862        assert!(file_index.has_anchor("Install")); // case-insensitive
1863
1864        // Should not find non-existent anchor
1865        assert!(!file_index.has_anchor("nonexistent"));
1866    }
1867
1868    #[test]
1869    fn test_get_heading_by_anchor() {
1870        let mut file_index = FileIndex::new();
1871        file_index.add_heading(HeadingIndex {
1872            text: "Installation Guide".to_string(),
1873            auto_anchor: "installation-guide".to_string(),
1874            custom_anchor: Some("install".to_string()),
1875            line: 10,
1876            is_setext: false,
1877        });
1878        file_index.add_heading(HeadingIndex {
1879            text: "Configuration".to_string(),
1880            auto_anchor: "configuration".to_string(),
1881            custom_anchor: None,
1882            line: 20,
1883            is_setext: false,
1884        });
1885
1886        // Get by auto anchor
1887        let heading = file_index.get_heading_by_anchor("installation-guide");
1888        assert!(heading.is_some());
1889        assert_eq!(heading.unwrap().text, "Installation Guide");
1890        assert_eq!(heading.unwrap().line, 10);
1891
1892        // Get by custom anchor
1893        let heading = file_index.get_heading_by_anchor("install");
1894        assert!(heading.is_some());
1895        assert_eq!(heading.unwrap().text, "Installation Guide");
1896
1897        // Get second heading
1898        let heading = file_index.get_heading_by_anchor("configuration");
1899        assert!(heading.is_some());
1900        assert_eq!(heading.unwrap().text, "Configuration");
1901        assert_eq!(heading.unwrap().line, 20);
1902
1903        // Non-existent
1904        assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1905    }
1906
1907    #[test]
1908    fn test_anchor_lookup_many_headings() {
1909        // Test that O(1) lookup works with many headings
1910        let mut file_index = FileIndex::new();
1911
1912        // Add 100 headings
1913        for i in 0..100 {
1914            file_index.add_heading(HeadingIndex {
1915                text: format!("Heading {i}"),
1916                auto_anchor: format!("heading-{i}"),
1917                custom_anchor: Some(format!("h{i}")),
1918                line: i + 1,
1919                is_setext: false,
1920            });
1921        }
1922
1923        // Verify all can be found
1924        for i in 0..100 {
1925            assert!(file_index.has_anchor(&format!("heading-{i}")));
1926            assert!(file_index.has_anchor(&format!("h{i}")));
1927
1928            let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1929            assert!(heading.is_some());
1930            assert_eq!(heading.unwrap().line, i + 1);
1931        }
1932    }
1933
1934    // =============================================================================
1935    // Tests for extract_cross_file_links utility
1936    // =============================================================================
1937
1938    #[test]
1939    fn test_extract_cross_file_links_basic() {
1940        use crate::config::MarkdownFlavor;
1941
1942        let content = "# Test\n\nSee [link](./other.md) for info.\n";
1943        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1944        let links = extract_cross_file_links(&ctx).relative;
1945
1946        assert_eq!(links.len(), 1);
1947        assert_eq!(links[0].target_path, "./other.md");
1948        assert_eq!(links[0].fragment, "");
1949        assert_eq!(links[0].line, 3);
1950        // "See [link](" = 11 chars, so column 12 is where "./other.md" starts
1951        assert_eq!(links[0].column, 12);
1952    }
1953
1954    #[test]
1955    fn test_extract_cross_file_links_with_fragment() {
1956        use crate::config::MarkdownFlavor;
1957
1958        let content = "Check [guide](./guide.md#install) here.\n";
1959        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1960        let links = extract_cross_file_links(&ctx).relative;
1961
1962        assert_eq!(links.len(), 1);
1963        assert_eq!(links[0].target_path, "./guide.md");
1964        assert_eq!(links[0].fragment, "install");
1965        assert_eq!(links[0].line, 1);
1966        // "Check [guide](" = 14 chars, so column 15 is where "./guide.md" starts
1967        assert_eq!(links[0].column, 15);
1968    }
1969
1970    #[test]
1971    fn test_extract_cross_file_links_multiple_on_same_line() {
1972        use crate::config::MarkdownFlavor;
1973
1974        let content = "See [a](a.md) and [b](b.md) here.\n";
1975        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1976        let links = extract_cross_file_links(&ctx).relative;
1977
1978        assert_eq!(links.len(), 2);
1979
1980        assert_eq!(links[0].target_path, "a.md");
1981        assert_eq!(links[0].line, 1);
1982        // "See [a](" = 8 chars, so column 9
1983        assert_eq!(links[0].column, 9);
1984
1985        assert_eq!(links[1].target_path, "b.md");
1986        assert_eq!(links[1].line, 1);
1987        // "See [a](a.md) and [b](" = 22 chars, so column 23
1988        assert_eq!(links[1].column, 23);
1989    }
1990
1991    #[test]
1992    fn test_extract_cross_file_links_angle_brackets() {
1993        use crate::config::MarkdownFlavor;
1994
1995        let content = "See [link](<path/with (parens).md>) here.\n";
1996        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1997        let links = extract_cross_file_links(&ctx).relative;
1998
1999        assert_eq!(links.len(), 1);
2000        assert_eq!(links[0].target_path, "path/with (parens).md");
2001        assert_eq!(links[0].line, 1);
2002        // "See [link](<" = 12 chars, so column 13
2003        assert_eq!(links[0].column, 13);
2004    }
2005
2006    #[test]
2007    fn test_extract_cross_file_links_skips_external() {
2008        use crate::config::MarkdownFlavor;
2009
2010        let content = r#"
2011[external](https://example.com)
2012[mailto](mailto:test@example.com)
2013[local](./local.md)
2014[fragment](#section)
2015[absolute](/docs/page.md)
2016"#;
2017        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2018        let extracted = extract_cross_file_links(&ctx);
2019
2020        // Only the local markdown link is a directory-relative link.
2021        assert_eq!(extracted.relative.len(), 1);
2022        assert_eq!(extracted.relative[0].target_path, "./local.md");
2023        // The root-relative link is captured separately, leading `/` stripped.
2024        assert_eq!(extracted.root_relative.len(), 1);
2025        assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
2026    }
2027
2028    #[test]
2029    fn test_extract_cross_file_links_root_relative() {
2030        use crate::config::MarkdownFlavor;
2031
2032        // Root-relative markdown links land in the root_relative bucket with the
2033        // leading `/` stripped; parent traversal and protocol-relative links are
2034        // excluded so they cannot escape a content root.
2035        let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
2036        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2037        let extracted = extract_cross_file_links(&ctx);
2038
2039        assert!(extracted.relative.is_empty(), "no directory-relative links here");
2040        assert_eq!(
2041            extracted
2042                .root_relative
2043                .iter()
2044                .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
2045                .collect::<Vec<_>>(),
2046            vec![("guide.md", "install")],
2047            "only the safe root-relative markdown link is captured"
2048        );
2049    }
2050
2051    #[test]
2052    fn test_extract_cross_file_links_skips_non_markdown() {
2053        use crate::config::MarkdownFlavor;
2054
2055        let content = r#"
2056[image](./photo.png)
2057[doc](./readme.md)
2058[pdf](./document.pdf)
2059"#;
2060        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2061        let links = extract_cross_file_links(&ctx).relative;
2062
2063        // Only markdown files are indexed for cross-file validation
2064        assert_eq!(links.len(), 1);
2065        assert_eq!(links[0].target_path, "./readme.md");
2066    }
2067
2068    #[test]
2069    fn test_extract_cross_file_links_skips_code_spans() {
2070        use crate::config::MarkdownFlavor;
2071
2072        let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
2073        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2074        let links = extract_cross_file_links(&ctx).relative;
2075
2076        // Only the link outside code span should be extracted
2077        assert_eq!(links.len(), 1);
2078        assert_eq!(links[0].target_path, "./file.md");
2079    }
2080
2081    #[test]
2082    fn test_extract_cross_file_links_with_query_params() {
2083        use crate::config::MarkdownFlavor;
2084
2085        let content = "See [doc](./file.md?raw=true) here.\n";
2086        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2087        let links = extract_cross_file_links(&ctx).relative;
2088
2089        assert_eq!(links.len(), 1);
2090        // Query params should be stripped
2091        assert_eq!(links[0].target_path, "./file.md");
2092    }
2093
2094    #[test]
2095    fn test_extract_cross_file_links_empty_content() {
2096        use crate::config::MarkdownFlavor;
2097
2098        let content = "";
2099        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2100        let links = extract_cross_file_links(&ctx).relative;
2101
2102        assert!(links.is_empty());
2103    }
2104
2105    #[test]
2106    fn test_extract_cross_file_links_no_links() {
2107        use crate::config::MarkdownFlavor;
2108
2109        let content = "# Just a heading\n\nSome text without links.\n";
2110        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2111        let links = extract_cross_file_links(&ctx).relative;
2112
2113        assert!(links.is_empty());
2114    }
2115
2116    #[test]
2117    fn test_extract_cross_file_links_position_accuracy_issue_234() {
2118        // This test verifies the fix for GitHub issue #234
2119        // The LSP was reporting incorrect column positions for MD057 diagnostics
2120        use crate::config::MarkdownFlavor;
2121
2122        let content = r#"# Test Document
2123
2124Here is a [broken link](nonexistent-file.md) that should trigger MD057.
2125
2126And another [link](also-missing.md) on this line.
2127"#;
2128        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
2129        let links = extract_cross_file_links(&ctx).relative;
2130
2131        assert_eq!(links.len(), 2);
2132
2133        // First link: "Here is a [broken link](" = 24 chars, column 25
2134        assert_eq!(links[0].target_path, "nonexistent-file.md");
2135        assert_eq!(links[0].line, 3);
2136        assert_eq!(links[0].column, 25);
2137
2138        // Second link: "And another [link](" = 19 chars, column 20
2139        assert_eq!(links[1].target_path, "also-missing.md");
2140        assert_eq!(links[1].line, 5);
2141        assert_eq!(links[1].column, 20);
2142    }
2143}