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