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/// Markdown file links extracted from a document, split by how they resolve.
122///
123/// Linting rules only understand `relative` links (resolved against the source
124/// file's directory). `root_relative` links (leading `/`) are an LSP concept
125/// resolved against the configured content roots, so they are kept separate to
126/// avoid changing linting behavior.
127#[derive(Debug, Default)]
128pub struct ExtractedCrossFileLinks {
129    /// Links resolved relative to the source file's directory.
130    pub relative: Vec<CrossFileLinkIndex>,
131    /// Root-relative links. `target_path` has the leading `/` stripped so it can
132    /// be joined directly to a content root. Parent-traversal and
133    /// protocol-relative (`//host`) links are excluded.
134    pub root_relative: Vec<CrossFileLinkIndex>,
135}
136
137/// Extract cross-file links from content using correct regex-based position tracking.
138///
139/// This is the canonical implementation used by both MD057 and LSP to ensure
140/// consistent and correct column positions for diagnostic reporting.
141///
142/// Returns one `CrossFileLinkIndex` per markdown file link, split into directory
143/// relative links and root-relative links (see `ExtractedCrossFileLinks`).
144pub fn extract_cross_file_links(ctx: &LintContext) -> ExtractedCrossFileLinks {
145    let content = ctx.content;
146
147    // Early returns for performance
148    if content.is_empty() || !content.contains("](") {
149        return ExtractedCrossFileLinks::default();
150    }
151
152    let mut links = ExtractedCrossFileLinks::default();
153    let lines: Vec<&str> = content.lines().collect();
154    let line_index = &ctx.line_index;
155
156    // Track which lines we've already processed to avoid duplicates
157    // (ctx.links may have multiple entries for the same line)
158    let mut processed_lines = HashSet::new();
159
160    for link in &ctx.links {
161        let line_idx = link.line - 1;
162        if line_idx >= lines.len() {
163            continue;
164        }
165
166        // Skip if we've already processed this line
167        if !processed_lines.insert(line_idx) {
168            continue;
169        }
170
171        let line = lines[line_idx];
172        if !line.contains("](") {
173            continue;
174        }
175
176        // Find all links in this line
177        for link_match in LINK_START_REGEX.find_iter(line) {
178            let start_pos = link_match.start();
179            let end_pos = link_match.end();
180
181            // Calculate absolute position for code span detection
182            let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
183            let absolute_start_pos = line_start_byte + start_pos;
184
185            // Skip if in code span
186            if ctx.is_in_code_span_byte(absolute_start_pos) {
187                continue;
188            }
189
190            // Extract the URL (group 1) and fragment (group 2)
191            // Try angle-bracket regex first (handles URLs with parens)
192            let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
193                .captures_at(line, end_pos - 1)
194                .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
195
196            if let Some(caps) = caps_result
197                && let Some(url_group) = caps.get(1)
198            {
199                let file_path = url_group.as_str().trim();
200
201                // Root-relative links (leading `/`) resolve against content roots
202                // in the LSP, not the source directory, so they are captured in a
203                // separate bucket. Protocol-relative (`//host`) and parent-traversal
204                // links are excluded so they cannot escape a content root.
205                if let Some(rel) = file_path.strip_prefix('/') {
206                    if !rel.starts_with('/')
207                        && !Path::new(rel)
208                            .components()
209                            .any(|c| matches!(c, std::path::Component::ParentDir))
210                    {
211                        let stripped = strip_query_and_fragment(rel);
212                        if is_markdown_file(stripped) {
213                            let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
214                            links.root_relative.push(CrossFileLinkIndex {
215                                target_path: stripped.to_string(),
216                                fragment: fragment.to_string(),
217                                line: link.line,
218                                column: byte_to_char_count(line, url_group.start()),
219                            });
220                        }
221                    }
222                    continue;
223                }
224
225                // Skip empty, external, template variables, framework aliases,
226                // fragment-only URLs, or rustdoc intra-doc links
227                if file_path.is_empty()
228                    || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
229                    || file_path.starts_with("www.")
230                    || file_path.starts_with('#')
231                    || file_path.starts_with("{{")
232                    || file_path.starts_with("{%")
233                    || file_path.starts_with('~')
234                    || file_path.starts_with('@')
235                    || (file_path.starts_with('`') && file_path.ends_with('`'))
236                {
237                    continue;
238                }
239
240                // Strip query parameters before indexing
241                let file_path = strip_query_and_fragment(file_path);
242
243                // Get fragment from capture group 2 (includes # prefix)
244                let fragment = caps.get(2).map_or("", |m| m.as_str().trim_start_matches('#'));
245
246                // Only index markdown file links for cross-file validation
247                if is_markdown_file(file_path) {
248                    links.relative.push(CrossFileLinkIndex {
249                        target_path: file_path.to_string(),
250                        fragment: fragment.to_string(),
251                        line: link.line,
252                        column: byte_to_char_count(line, url_group.start()),
253                    });
254                }
255            }
256        }
257    }
258
259    links
260}
261
262/// Magic bytes identifying a workspace index cache file
263#[cfg(feature = "native")]
264const CACHE_MAGIC: &[u8; 4] = b"RWSI";
265
266/// Cache format version - increment when WorkspaceIndex serialization changes
267/// or when the meaning of persisted fields changes such that older caches are
268/// no longer correct. Version 8 forces a rebuild so the new `root_relative_links`
269/// field is populated; earlier caches lack it, leaving find-references unable to
270/// discover root-relative (`/path`) links until a rescan.
271#[cfg(feature = "native")]
272const CACHE_FORMAT_VERSION: u32 = 8;
273
274/// Cache file name within the version directory
275#[cfg(feature = "native")]
276const CACHE_FILE_NAME: &str = "workspace_index.bin";
277
278/// Workspace-wide index for cross-file analysis
279///
280/// Contains pre-extracted information from all markdown files in the workspace,
281/// enabling rules to validate cross-file references efficiently.
282#[derive(Debug, Default, Clone, Serialize, Deserialize)]
283pub struct WorkspaceIndex {
284    /// Map from file path to its extracted data
285    files: HashMap<PathBuf, FileIndex>,
286    /// Reverse dependency graph: target file → files that link to it
287    /// Used to efficiently re-lint dependent files when a target changes
288    reverse_deps: HashMap<PathBuf, HashSet<PathBuf>>,
289    /// Version counter for cache invalidation (incremented on any change)
290    version: u64,
291}
292
293/// Index data extracted from a single file
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
295pub struct FileIndex {
296    /// Headings in this file with their anchors
297    pub headings: Vec<HeadingIndex>,
298    /// Reference links in this file (for cross-file analysis)
299    pub reference_links: Vec<ReferenceLinkIndex>,
300    /// Cross-file links in this file (for MD051 cross-file validation)
301    pub cross_file_links: Vec<CrossFileLinkIndex>,
302    /// Root-relative links (leading `/`) in this file. Resolved against the
303    /// configured content roots by the LSP for go-to-definition, hover, and
304    /// find-references. `target_path` has the leading `/` stripped. Linting does
305    /// not use these, so they never affect diagnostics.
306    #[serde(default)]
307    pub root_relative_links: Vec<CrossFileLinkIndex>,
308    /// Defined reference IDs (e.g., from `[ref]: url` definitions)
309    /// Used to filter out reference links that have explicit definitions
310    pub defined_references: HashSet<String>,
311    /// Content hash for change detection
312    pub content_hash: String,
313    /// O(1) anchor lookup: lowercased anchor → heading index
314    /// Includes both auto-generated and custom anchors
315    anchor_to_heading: HashMap<String, usize>,
316    /// O(1) anchor lookup with original case preserved → heading index.
317    /// Used for `ignore_case = false` (markdownlint strict parity). Skipped at
318    /// query time when the lowercase map is sufficient.
319    #[serde(default)]
320    anchor_to_heading_exact: HashMap<String, usize>,
321    /// HTML anchors defined via `<a id="...">` or `<element id="...">` tags.
322    /// Stored lowercase for case-insensitive matching.
323    html_anchors: HashSet<String>,
324    /// HTML anchors with original case preserved.
325    /// Used for `ignore_case = false` (markdownlint strict parity).
326    #[serde(default)]
327    html_anchors_exact: HashSet<String>,
328    /// Attribute anchors defined via { #id } syntax (kramdown/MkDocs attr_list).
329    /// Can appear on any element, not just headings.
330    /// Stored lowercase for case-insensitive matching.
331    attribute_anchors: HashSet<String>,
332    /// Attribute anchors with original case preserved.
333    /// Used for `ignore_case = false` (markdownlint strict parity).
334    #[serde(default)]
335    attribute_anchors_exact: HashSet<String>,
336    /// Rules disabled for the entire file (from inline comments)
337    /// Used by cross-file rules to respect inline disable directives
338    pub file_disabled_rules: HashSet<String>,
339    /// Persistent disable/enable state transitions, sorted by line number.
340    /// Each entry: (line, disabled_rules, enabled_rules). Use binary search to query.
341    pub persistent_transitions: Vec<(usize, HashSet<String>, HashSet<String>)>,
342    /// Rules disabled at specific lines via disable-line / disable-next-line
343    pub line_disabled_rules: HashMap<usize, HashSet<String>>,
344}
345
346/// Information about a heading for cross-file lookup
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct HeadingIndex {
349    /// The heading text (e.g., "Installation Guide")
350    pub text: String,
351    /// Auto-generated anchor (e.g., "installation-guide")
352    pub auto_anchor: String,
353    /// Custom anchor if present (e.g., "install")
354    pub custom_anchor: Option<String>,
355    /// Line number (1-indexed)
356    pub line: usize,
357    /// Whether this is a Setext-style heading (underlined with = or -)
358    #[serde(default)]
359    pub is_setext: bool,
360}
361
362/// Information about a reference link for cross-file analysis
363#[derive(Debug, Clone, Serialize, Deserialize)]
364pub struct ReferenceLinkIndex {
365    /// The reference ID (the part in `[text][ref]`)
366    pub reference_id: String,
367    /// Line number (1-indexed)
368    pub line: usize,
369    /// Column number (1-indexed)
370    pub column: usize,
371}
372
373/// Information about a cross-file link for validation
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct CrossFileLinkIndex {
376    /// The target file path (relative, as it appears in the link)
377    pub target_path: String,
378    /// The fragment/anchor being linked to (without #)
379    pub fragment: String,
380    /// Line number (1-indexed)
381    pub line: usize,
382    /// Column number (1-indexed)
383    pub column: usize,
384}
385
386/// Information about a vulnerable anchor (heading without custom ID)
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct VulnerableAnchor {
389    /// File path where the heading is located
390    pub file: PathBuf,
391    /// Line number of the heading
392    pub line: usize,
393    /// The heading text
394    pub text: String,
395}
396
397impl WorkspaceIndex {
398    /// Create a new empty workspace index
399    pub fn new() -> Self {
400        Self::default()
401    }
402
403    /// Get the current version (for cache invalidation)
404    pub fn version(&self) -> u64 {
405        self.version
406    }
407
408    /// Get the number of indexed files
409    pub fn file_count(&self) -> usize {
410        self.files.len()
411    }
412
413    /// Check if a file is in the index
414    pub fn contains_file(&self, path: &Path) -> bool {
415        self.files.contains_key(path)
416    }
417
418    /// Get the index data for a specific file
419    pub fn get_file(&self, path: &Path) -> Option<&FileIndex> {
420        self.files.get(path)
421    }
422
423    /// Insert or update a file's index data
424    pub fn insert_file(&mut self, path: PathBuf, index: FileIndex) {
425        self.files.insert(path, index);
426        self.version = self.version.wrapping_add(1);
427    }
428
429    /// Remove a file from the index
430    pub fn remove_file(&mut self, path: &Path) -> Option<FileIndex> {
431        // Clean up reverse deps for this file
432        self.clear_reverse_deps_for(path);
433
434        let result = self.files.remove(path);
435        if result.is_some() {
436            self.version = self.version.wrapping_add(1);
437        }
438        result
439    }
440
441    /// Build a map of all "vulnerable" anchors across the workspace
442    ///
443    /// A vulnerable anchor is an auto-generated anchor for a heading that
444    /// does NOT have a custom anchor defined. These are problematic for
445    /// translated content because the anchor changes when the heading is translated.
446    ///
447    /// Returns: Map from lowercase anchor → Vec of VulnerableAnchor info
448    /// Multiple files can have headings with the same auto-generated anchor,
449    /// so we collect all occurrences.
450    pub fn get_vulnerable_anchors(&self) -> HashMap<String, Vec<VulnerableAnchor>> {
451        let mut vulnerable: HashMap<String, Vec<VulnerableAnchor>> = HashMap::new();
452
453        for (file_path, file_index) in &self.files {
454            for heading in &file_index.headings {
455                // Only include headings WITHOUT custom anchors
456                if heading.custom_anchor.is_none() && !heading.auto_anchor.is_empty() {
457                    let anchor_key = heading.auto_anchor.to_lowercase();
458                    vulnerable.entry(anchor_key).or_default().push(VulnerableAnchor {
459                        file: file_path.clone(),
460                        line: heading.line,
461                        text: heading.text.clone(),
462                    });
463                }
464            }
465        }
466
467        vulnerable
468    }
469
470    /// Get all headings across the workspace (for debugging/testing)
471    pub fn all_headings(&self) -> impl Iterator<Item = (&Path, &HeadingIndex)> {
472        self.files
473            .iter()
474            .flat_map(|(path, index)| index.headings.iter().map(move |h| (path.as_path(), h)))
475    }
476
477    /// Iterate over all files in the index
478    pub fn files(&self) -> impl Iterator<Item = (&Path, &FileIndex)> {
479        self.files.iter().map(|(p, i)| (p.as_path(), i))
480    }
481
482    /// All files in the index, ordered by path.
483    ///
484    /// `files()` iterates the backing `HashMap` in an unspecified order, so
485    /// consumers that emit output (cross-file diagnostics) must use this to
486    /// keep results stable across runs on identical input.
487    pub fn files_sorted(&self) -> Vec<(&Path, &FileIndex)> {
488        let mut entries: Vec<(&Path, &FileIndex)> = self.files.iter().map(|(p, i)| (p.as_path(), i)).collect();
489        entries.sort_by_key(|(a, _)| *a);
490        entries
491    }
492
493    /// Clear the entire index
494    pub fn clear(&mut self) {
495        self.files.clear();
496        self.reverse_deps.clear();
497        self.version = self.version.wrapping_add(1);
498    }
499
500    /// Update a file's index and maintain reverse dependencies
501    ///
502    /// This method:
503    /// 1. Removes this file as a source (dependent) from all reverse deps
504    /// 2. Inserts the new file index
505    /// 3. Builds new reverse deps from cross_file_links
506    pub fn update_file(&mut self, path: &Path, index: FileIndex) {
507        // Remove this file as a source (dependent) from all target entries
508        // Note: We don't remove it as a target - other files may still link to it
509        self.clear_reverse_deps_as_source(path);
510
511        // Build new reverse deps from cross_file_links
512        for link in &index.cross_file_links {
513            let target = self.resolve_target_path(path, &link.target_path);
514            self.reverse_deps.entry(target).or_default().insert(path.to_path_buf());
515        }
516
517        self.files.insert(path.to_path_buf(), index);
518        self.version = self.version.wrapping_add(1);
519    }
520
521    /// Get files that depend on (link to) the given file
522    ///
523    /// Returns a list of file paths that contain links targeting this file.
524    /// Used to re-lint dependent files when a target file changes.
525    pub fn get_dependents(&self, path: &Path) -> Vec<PathBuf> {
526        self.reverse_deps
527            .get(path)
528            .map(|set| set.iter().cloned().collect())
529            .unwrap_or_default()
530    }
531
532    /// Check if a file needs re-indexing based on its content hash
533    ///
534    /// Returns `true` if the file is not in the index or has a different hash.
535    pub fn is_file_stale(&self, path: &Path, current_hash: &str) -> bool {
536        self.files.get(path).is_none_or(|f| f.content_hash != current_hash)
537    }
538
539    /// Retain only files that exist in the given set, removing deleted files
540    ///
541    /// This prunes stale entries from the cache for files that no longer exist.
542    /// Returns the number of files removed.
543    pub fn retain_only(&mut self, current_files: &std::collections::HashSet<PathBuf>) -> usize {
544        let before_count = self.files.len();
545
546        // Collect files to remove
547        let to_remove: Vec<PathBuf> = self
548            .files
549            .keys()
550            .filter(|path| !current_files.contains(*path))
551            .cloned()
552            .collect();
553
554        // Remove each file properly (clears reverse deps)
555        for path in &to_remove {
556            self.remove_file(path);
557        }
558
559        before_count - self.files.len()
560    }
561
562    /// Save the workspace index to a cache file
563    ///
564    /// Uses postcard for efficient binary serialization with:
565    /// - Magic header for file type validation
566    /// - Format version for compatibility detection
567    /// - Atomic writes (temp file + rename) to prevent corruption
568    #[cfg(feature = "native")]
569    pub fn save_to_cache(&self, cache_dir: &Path) -> std::io::Result<()> {
570        use std::fs;
571        use std::io::Write;
572
573        // Ensure cache directory exists
574        fs::create_dir_all(cache_dir)?;
575
576        // Serialize the index data using postcard
577        let encoded = postcard::to_allocvec(self)
578            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
579
580        // Build versioned cache file: [magic][version][data]
581        let mut cache_data = Vec::with_capacity(8 + encoded.len());
582        cache_data.extend_from_slice(CACHE_MAGIC);
583        cache_data.extend_from_slice(&CACHE_FORMAT_VERSION.to_le_bytes());
584        cache_data.extend_from_slice(&encoded);
585
586        // Write atomically: write to temp file then rename
587        let final_path = cache_dir.join(CACHE_FILE_NAME);
588        let temp_path = cache_dir.join(format!("{}.tmp.{}", CACHE_FILE_NAME, std::process::id()));
589
590        // Write to temp file
591        {
592            let mut file = fs::File::create(&temp_path)?;
593            file.write_all(&cache_data)?;
594            file.sync_all()?;
595        }
596
597        // Atomic rename
598        fs::rename(&temp_path, &final_path)?;
599
600        log::debug!(
601            "Saved workspace index to cache: {} files, {} bytes (format v{})",
602            self.files.len(),
603            cache_data.len(),
604            CACHE_FORMAT_VERSION
605        );
606
607        Ok(())
608    }
609
610    /// Load the workspace index from a cache file
611    ///
612    /// Returns `None` if:
613    /// - Cache file doesn't exist
614    /// - Magic header doesn't match
615    /// - Format version is incompatible
616    /// - Data is corrupted
617    #[cfg(feature = "native")]
618    pub fn load_from_cache(cache_dir: &Path) -> Option<Self> {
619        use std::fs;
620
621        let path = cache_dir.join(CACHE_FILE_NAME);
622        let data = fs::read(&path).ok()?;
623
624        // Validate header: need at least 8 bytes for magic + version
625        if data.len() < 8 {
626            log::warn!("Workspace index cache too small, discarding");
627            let _ = fs::remove_file(&path);
628            return None;
629        }
630
631        // Check magic header
632        if &data[0..4] != CACHE_MAGIC {
633            log::warn!("Workspace index cache has invalid magic header, discarding");
634            let _ = fs::remove_file(&path);
635            return None;
636        }
637
638        // Check format version
639        let version = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
640        if version != CACHE_FORMAT_VERSION {
641            log::info!(
642                "Workspace index cache format version mismatch (got {version}, expected {CACHE_FORMAT_VERSION}), rebuilding"
643            );
644            let _ = fs::remove_file(&path);
645            return None;
646        }
647
648        // Deserialize the index data using postcard
649        match postcard::from_bytes::<Self>(&data[8..]) {
650            Ok(index) => {
651                log::debug!(
652                    "Loaded workspace index from cache: {} files (format v{})",
653                    index.files.len(),
654                    version
655                );
656                Some(index)
657            }
658            Err(e) => {
659                log::warn!("Failed to deserialize workspace index cache: {e}");
660                let _ = fs::remove_file(&path);
661                None
662            }
663        }
664    }
665
666    /// Remove a file as a source from all reverse dependency entries
667    ///
668    /// This removes the file from being listed as a dependent in all target entries.
669    /// Used when updating a file (we need to remove old outgoing links before adding new ones).
670    fn clear_reverse_deps_as_source(&mut self, path: &Path) {
671        // Remove `path` as a dependent only from the targets it actually links
672        // to, discovered from its current index, instead of scanning every
673        // entry in reverse_deps. Both callers (update_file, clear_reverse_deps_for)
674        // run this before the file's entry is replaced/removed, so self.files[path]
675        // still holds the links that produced these reverse-dep entries; resolving
676        // them the same way reverses exactly the prior insertions.
677        let targets: Vec<PathBuf> = match self.files.get(path) {
678            Some(index) => index
679                .cross_file_links
680                .iter()
681                .map(|link| self.resolve_target_path(path, &link.target_path))
682                .collect(),
683            None => return,
684        };
685        for target in targets {
686            if let Some(deps) = self.reverse_deps.get_mut(&target) {
687                deps.remove(path);
688                if deps.is_empty() {
689                    self.reverse_deps.remove(&target);
690                }
691            }
692        }
693    }
694
695    /// Remove a file completely from reverse dependency tracking
696    ///
697    /// Removes the file as both a source (dependent) and as a target.
698    /// Used when deleting a file from the index.
699    fn clear_reverse_deps_for(&mut self, path: &Path) {
700        // Remove as source (dependent)
701        self.clear_reverse_deps_as_source(path);
702
703        // Also remove as target
704        self.reverse_deps.remove(path);
705    }
706
707    /// Resolve a relative path from a source file to an absolute target path
708    fn resolve_target_path(&self, source_file: &Path, relative_target: &str) -> PathBuf {
709        // Get the directory containing the source file
710        let source_dir = source_file.parent().unwrap_or(Path::new(""));
711
712        // Join with the relative target and normalize
713        let target = source_dir.join(relative_target);
714
715        // Normalize the path (handle .., ., etc.)
716        Self::normalize_path(&target)
717    }
718
719    /// Normalize a path by resolving . and .. components
720    fn normalize_path(path: &Path) -> PathBuf {
721        let mut components = Vec::new();
722
723        for component in path.components() {
724            match component {
725                std::path::Component::ParentDir => {
726                    // Go up one level if possible
727                    if !components.is_empty() {
728                        components.pop();
729                    }
730                }
731                std::path::Component::CurDir => {
732                    // Skip current directory markers
733                }
734                _ => {
735                    components.push(component);
736                }
737            }
738        }
739
740        components.iter().collect()
741    }
742}
743
744impl FileIndex {
745    /// Create a new empty file index
746    pub fn new() -> Self {
747        Self::default()
748    }
749
750    /// Create a file index with the given content hash
751    pub fn with_hash(content_hash: String) -> Self {
752        Self {
753            content_hash,
754            ..Default::default()
755        }
756    }
757
758    /// Add a heading to the index
759    ///
760    /// Also updates the anchor lookup maps for O(1) anchor queries. Both
761    /// lowercased (for `ignore_case = true`) and case-preserving (for
762    /// `ignore_case = false`) maps are populated.
763    pub fn add_heading(&mut self, heading: HeadingIndex) {
764        let index = self.headings.len();
765
766        // Auto-generated anchor — slugs are already lowercase, but we still
767        // populate both maps so query-time dispatch is uniform.
768        self.anchor_to_heading.insert(heading.auto_anchor.to_lowercase(), index);
769        self.anchor_to_heading_exact.insert(heading.auto_anchor.clone(), index);
770
771        // Custom anchor preserves original case as written by the author.
772        if let Some(ref custom) = heading.custom_anchor {
773            self.anchor_to_heading.insert(custom.to_lowercase(), index);
774            self.anchor_to_heading_exact.insert(custom.clone(), index);
775        }
776
777        self.headings.push(heading);
778    }
779
780    /// Add an alternative anchor that resolves to an existing heading.
781    /// Used for platform-specific anchor conventions (e.g., Python-Markdown `_N` dedup).
782    pub fn add_anchor_alias(&mut self, anchor: &str, heading_index: usize) {
783        if heading_index < self.headings.len() {
784            self.anchor_to_heading.insert(anchor.to_lowercase(), heading_index);
785            self.anchor_to_heading_exact.insert(anchor.to_string(), heading_index);
786        }
787    }
788
789    /// Check if an anchor exists in this file (O(1) lookup)
790    ///
791    /// Returns true if the anchor matches any of:
792    /// - Auto-generated heading anchors
793    /// - Custom heading anchors (from {#id} syntax on headings)
794    /// - HTML anchors (from `<a id="...">` or `<element id="...">`)
795    /// - Attribute anchors (from { #id } syntax on non-heading elements)
796    ///
797    /// Matching is case-insensitive. URL-encoded anchors (e.g., CJK characters
798    /// like `%E6%97%A5%E6%9C%AC%E8%AA%9E` for `日本語`) are decoded before matching.
799    pub fn has_anchor(&self, anchor: &str) -> bool {
800        self.has_anchor_with_case(anchor, true)
801    }
802
803    /// Check if an anchor exists in this file, with explicit case sensitivity.
804    ///
805    /// When `ignore_case` is `true`, behaves identically to [`has_anchor`] —
806    /// inputs are lowercased and matched against the lowercase storage.
807    /// When `false`, the input is compared as-is against parallel
808    /// case-preserving storage, matching markdownlint's strict behavior for
809    /// generated heading slugs, custom heading IDs, HTML anchors, and
810    /// attribute anchors.
811    pub fn has_anchor_with_case(&self, anchor: &str, ignore_case: bool) -> bool {
812        if self.lookup_anchor(anchor, ignore_case) {
813            return true;
814        }
815
816        // Slow path: if anchor contains percent-encoding, try decoded version
817        if anchor.contains('%') {
818            let decoded = url_decode(anchor);
819            if decoded != anchor {
820                return self.lookup_anchor(&decoded, ignore_case);
821            }
822        }
823
824        false
825    }
826
827    /// Direct anchor lookup, dispatching to the lowercase or exact-case
828    /// storage based on `ignore_case`.
829    fn lookup_anchor(&self, anchor: &str, ignore_case: bool) -> bool {
830        if ignore_case {
831            let lower = anchor.to_lowercase();
832            self.anchor_to_heading.contains_key(&lower)
833                || self.html_anchors.contains(&lower)
834                || self.attribute_anchors.contains(&lower)
835        } else {
836            self.anchor_to_heading_exact.contains_key(anchor)
837                || self.html_anchors_exact.contains(anchor)
838                || self.attribute_anchors_exact.contains(anchor)
839        }
840    }
841
842    /// Add an HTML anchor (from `<a id="...">` or `<element id="...">` tags).
843    /// Populates both lowercase (case-insensitive) and case-preserving sets.
844    pub fn add_html_anchor(&mut self, anchor: &str) {
845        if !anchor.is_empty() {
846            self.html_anchors.insert(anchor.to_lowercase());
847            self.html_anchors_exact.insert(anchor.to_string());
848        }
849    }
850
851    /// Add an attribute anchor (from { #id } syntax on non-heading elements).
852    /// Populates both lowercase (case-insensitive) and case-preserving sets.
853    pub fn add_attribute_anchor(&mut self, anchor: &str) {
854        if !anchor.is_empty() {
855            self.attribute_anchors.insert(anchor.to_lowercase());
856            self.attribute_anchors_exact.insert(anchor.to_string());
857        }
858    }
859
860    /// Get the heading index for an anchor (O(1) lookup)
861    ///
862    /// Returns the index into `self.headings` if found.
863    pub fn get_heading_by_anchor(&self, anchor: &str) -> Option<&HeadingIndex> {
864        self.anchor_to_heading
865            .get(&anchor.to_lowercase())
866            .and_then(|&idx| self.headings.get(idx))
867    }
868
869    /// Add a reference link to the index
870    pub fn add_reference_link(&mut self, link: ReferenceLinkIndex) {
871        self.reference_links.push(link);
872    }
873
874    /// Check if a rule is disabled at a specific line
875    ///
876    /// Used by cross-file rules to respect inline disable directives.
877    /// Checks both file-wide disables and line-specific disables.
878    pub fn is_rule_disabled_at_line(&self, rule_name: &str, line: usize) -> bool {
879        // Check file-wide disables (highest priority)
880        if self.file_disabled_rules.contains("*") || self.file_disabled_rules.contains(rule_name) {
881            return true;
882        }
883
884        // Check line-specific disables (disable-line / disable-next-line)
885        if let Some(rules) = self.line_disabled_rules.get(&line)
886            && (rules.contains("*") || rules.contains(rule_name))
887        {
888            return true;
889        }
890
891        // Check persistent disable/enable transitions via binary search
892        if !self.persistent_transitions.is_empty() {
893            let idx = match self.persistent_transitions.binary_search_by_key(&line, |t| t.0) {
894                Ok(i) => Some(i),
895                Err(i) => {
896                    if i > 0 {
897                        Some(i - 1)
898                    } else {
899                        None
900                    }
901                }
902            };
903            if let Some(i) = idx {
904                let (_, ref disabled, ref enabled) = self.persistent_transitions[i];
905                if disabled.contains("*") {
906                    return !enabled.contains(rule_name);
907                }
908                return disabled.contains(rule_name);
909            }
910        }
911
912        false
913    }
914
915    /// Add a cross-file link to the index (deduplicates by target_path, fragment, line)
916    pub fn add_cross_file_link(&mut self, link: CrossFileLinkIndex) {
917        // Deduplicate: multiple rules may contribute the same link with different columns
918        // (e.g., MD051 uses link start, MD057 uses URL start)
919        let is_duplicate = self.cross_file_links.iter().any(|existing| {
920            existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
921        });
922        if !is_duplicate {
923            self.cross_file_links.push(link);
924        }
925    }
926
927    /// Add a root-relative link to the index (deduplicates by target_path, fragment, line)
928    pub fn add_root_relative_link(&mut self, link: CrossFileLinkIndex) {
929        let is_duplicate = self.root_relative_links.iter().any(|existing| {
930            existing.target_path == link.target_path && existing.fragment == link.fragment && existing.line == link.line
931        });
932        if !is_duplicate {
933            self.root_relative_links.push(link);
934        }
935    }
936
937    /// Add a defined reference ID (e.g., from `[ref]: url`)
938    pub fn add_defined_reference(&mut self, ref_id: String) {
939        self.defined_references.insert(ref_id);
940    }
941
942    /// Check if a reference ID has an explicit definition
943    pub fn has_defined_reference(&self, ref_id: &str) -> bool {
944        self.defined_references.contains(ref_id)
945    }
946
947    /// Check if the content hash matches
948    pub fn hash_matches(&self, hash: &str) -> bool {
949        self.content_hash == hash
950    }
951
952    /// Get the number of headings
953    pub fn heading_count(&self) -> usize {
954        self.headings.len()
955    }
956
957    /// Get the number of reference links
958    pub fn reference_link_count(&self) -> usize {
959        self.reference_links.len()
960    }
961}
962
963#[cfg(test)]
964mod tests {
965    use super::*;
966
967    #[test]
968    fn test_workspace_index_basic() {
969        let mut index = WorkspaceIndex::new();
970        assert_eq!(index.file_count(), 0);
971        assert_eq!(index.version(), 0);
972
973        let mut file_index = FileIndex::with_hash("abc123".to_string());
974        file_index.add_heading(HeadingIndex {
975            text: "Installation".to_string(),
976            auto_anchor: "installation".to_string(),
977            custom_anchor: None,
978            line: 1,
979            is_setext: false,
980        });
981
982        index.insert_file(PathBuf::from("docs/install.md"), file_index);
983        assert_eq!(index.file_count(), 1);
984        assert_eq!(index.version(), 1);
985
986        assert!(index.contains_file(Path::new("docs/install.md")));
987        assert!(!index.contains_file(Path::new("docs/other.md")));
988    }
989
990    #[test]
991    fn test_vulnerable_anchors() {
992        let mut index = WorkspaceIndex::new();
993
994        // File 1: heading without custom anchor (vulnerable)
995        let mut file1 = FileIndex::new();
996        file1.add_heading(HeadingIndex {
997            text: "Getting Started".to_string(),
998            auto_anchor: "getting-started".to_string(),
999            custom_anchor: None,
1000            line: 1,
1001            is_setext: false,
1002        });
1003        index.insert_file(PathBuf::from("docs/guide.md"), file1);
1004
1005        // File 2: heading with custom anchor (not vulnerable)
1006        let mut file2 = FileIndex::new();
1007        file2.add_heading(HeadingIndex {
1008            text: "Installation".to_string(),
1009            auto_anchor: "installation".to_string(),
1010            custom_anchor: Some("install".to_string()),
1011            line: 1,
1012            is_setext: false,
1013        });
1014        index.insert_file(PathBuf::from("docs/install.md"), file2);
1015
1016        let vulnerable = index.get_vulnerable_anchors();
1017        assert_eq!(vulnerable.len(), 1);
1018        assert!(vulnerable.contains_key("getting-started"));
1019        assert!(!vulnerable.contains_key("installation"));
1020
1021        let anchors = vulnerable.get("getting-started").unwrap();
1022        assert_eq!(anchors.len(), 1);
1023        assert_eq!(anchors[0].file, PathBuf::from("docs/guide.md"));
1024        assert_eq!(anchors[0].text, "Getting Started");
1025    }
1026
1027    #[test]
1028    fn test_vulnerable_anchors_multiple_files_same_anchor() {
1029        // Multiple files can have headings with the same auto-generated anchor
1030        // get_vulnerable_anchors() should collect all of them
1031        let mut index = WorkspaceIndex::new();
1032
1033        // File 1: has "Installation" heading (vulnerable)
1034        let mut file1 = FileIndex::new();
1035        file1.add_heading(HeadingIndex {
1036            text: "Installation".to_string(),
1037            auto_anchor: "installation".to_string(),
1038            custom_anchor: None,
1039            line: 1,
1040            is_setext: false,
1041        });
1042        index.insert_file(PathBuf::from("docs/en/guide.md"), file1);
1043
1044        // File 2: also has "Installation" heading with same anchor (vulnerable)
1045        let mut file2 = FileIndex::new();
1046        file2.add_heading(HeadingIndex {
1047            text: "Installation".to_string(),
1048            auto_anchor: "installation".to_string(),
1049            custom_anchor: None,
1050            line: 5,
1051            is_setext: false,
1052        });
1053        index.insert_file(PathBuf::from("docs/fr/guide.md"), file2);
1054
1055        // File 3: has "Installation" but WITH custom anchor (not vulnerable)
1056        let mut file3 = FileIndex::new();
1057        file3.add_heading(HeadingIndex {
1058            text: "Installation".to_string(),
1059            auto_anchor: "installation".to_string(),
1060            custom_anchor: Some("install".to_string()),
1061            line: 10,
1062            is_setext: false,
1063        });
1064        index.insert_file(PathBuf::from("docs/de/guide.md"), file3);
1065
1066        let vulnerable = index.get_vulnerable_anchors();
1067        assert_eq!(vulnerable.len(), 1); // One unique anchor
1068        assert!(vulnerable.contains_key("installation"));
1069
1070        let anchors = vulnerable.get("installation").unwrap();
1071        // Should have 2 entries (en and fr), NOT 3 (de has custom anchor)
1072        assert_eq!(anchors.len(), 2, "Should collect both vulnerable anchors");
1073
1074        // Verify both files are represented
1075        let files: std::collections::HashSet<_> = anchors.iter().map(|a| &a.file).collect();
1076        assert!(files.contains(&PathBuf::from("docs/en/guide.md")));
1077        assert!(files.contains(&PathBuf::from("docs/fr/guide.md")));
1078    }
1079
1080    #[test]
1081    fn test_file_index_hash() {
1082        let index = FileIndex::with_hash("hash123".to_string());
1083        assert!(index.hash_matches("hash123"));
1084        assert!(!index.hash_matches("other"));
1085    }
1086
1087    #[test]
1088    fn test_version_increment() {
1089        let mut index = WorkspaceIndex::new();
1090        assert_eq!(index.version(), 0);
1091
1092        index.insert_file(PathBuf::from("a.md"), FileIndex::new());
1093        assert_eq!(index.version(), 1);
1094
1095        index.insert_file(PathBuf::from("b.md"), FileIndex::new());
1096        assert_eq!(index.version(), 2);
1097
1098        index.remove_file(Path::new("a.md"));
1099        assert_eq!(index.version(), 3);
1100
1101        // Removing non-existent file doesn't increment
1102        index.remove_file(Path::new("nonexistent.md"));
1103        assert_eq!(index.version(), 3);
1104    }
1105
1106    #[test]
1107    fn test_files_sorted_is_path_ordered() {
1108        let mut index = WorkspaceIndex::new();
1109        // Insert in a deliberately non-sorted order.
1110        for name in ["docs/zebra.md", "docs/apple.md", "docs/mango.md"] {
1111            index.update_file(Path::new(name), FileIndex::new());
1112        }
1113
1114        let paths: Vec<&Path> = index.files_sorted().into_iter().map(|(p, _)| p).collect();
1115        assert_eq!(
1116            paths,
1117            vec![
1118                Path::new("docs/apple.md"),
1119                Path::new("docs/mango.md"),
1120                Path::new("docs/zebra.md"),
1121            ],
1122            "files_sorted() must return entries ordered by path"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_reverse_deps_basic() {
1128        let mut index = WorkspaceIndex::new();
1129
1130        // File A links to file B
1131        let mut file_a = FileIndex::new();
1132        file_a.add_cross_file_link(CrossFileLinkIndex {
1133            target_path: "b.md".to_string(),
1134            fragment: "section".to_string(),
1135            line: 10,
1136            column: 5,
1137        });
1138        index.update_file(Path::new("docs/a.md"), file_a);
1139
1140        // Check that B has A as a dependent
1141        let dependents = index.get_dependents(Path::new("docs/b.md"));
1142        assert_eq!(dependents.len(), 1);
1143        assert_eq!(dependents[0], PathBuf::from("docs/a.md"));
1144
1145        // A has no dependents
1146        let a_dependents = index.get_dependents(Path::new("docs/a.md"));
1147        assert!(a_dependents.is_empty());
1148    }
1149
1150    #[test]
1151    fn test_reverse_deps_multiple() {
1152        let mut index = WorkspaceIndex::new();
1153
1154        // Files A and C both link to B
1155        let mut file_a = FileIndex::new();
1156        file_a.add_cross_file_link(CrossFileLinkIndex {
1157            target_path: "../b.md".to_string(),
1158            fragment: "".to_string(),
1159            line: 1,
1160            column: 1,
1161        });
1162        index.update_file(Path::new("docs/sub/a.md"), file_a);
1163
1164        let mut file_c = FileIndex::new();
1165        file_c.add_cross_file_link(CrossFileLinkIndex {
1166            target_path: "b.md".to_string(),
1167            fragment: "".to_string(),
1168            line: 1,
1169            column: 1,
1170        });
1171        index.update_file(Path::new("docs/c.md"), file_c);
1172
1173        // B should have both A and C as dependents
1174        let dependents = index.get_dependents(Path::new("docs/b.md"));
1175        assert_eq!(dependents.len(), 2);
1176        assert!(dependents.contains(&PathBuf::from("docs/sub/a.md")));
1177        assert!(dependents.contains(&PathBuf::from("docs/c.md")));
1178    }
1179
1180    #[test]
1181    fn test_reverse_deps_update_clears_old() {
1182        let mut index = WorkspaceIndex::new();
1183
1184        // File A initially links to B
1185        let mut file_a = FileIndex::new();
1186        file_a.add_cross_file_link(CrossFileLinkIndex {
1187            target_path: "b.md".to_string(),
1188            fragment: "".to_string(),
1189            line: 1,
1190            column: 1,
1191        });
1192        index.update_file(Path::new("docs/a.md"), file_a);
1193
1194        // Verify B has A as dependent
1195        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1196
1197        // Update A to link to C instead of B
1198        let mut file_a_updated = FileIndex::new();
1199        file_a_updated.add_cross_file_link(CrossFileLinkIndex {
1200            target_path: "c.md".to_string(),
1201            fragment: "".to_string(),
1202            line: 1,
1203            column: 1,
1204        });
1205        index.update_file(Path::new("docs/a.md"), file_a_updated);
1206
1207        // B should no longer have A as dependent
1208        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1209
1210        // C should now have A as dependent
1211        let c_deps = index.get_dependents(Path::new("docs/c.md"));
1212        assert_eq!(c_deps.len(), 1);
1213        assert_eq!(c_deps[0], PathBuf::from("docs/a.md"));
1214    }
1215
1216    #[test]
1217    fn test_reverse_deps_remove_file() {
1218        let mut index = WorkspaceIndex::new();
1219
1220        // File A links to B
1221        let mut file_a = FileIndex::new();
1222        file_a.add_cross_file_link(CrossFileLinkIndex {
1223            target_path: "b.md".to_string(),
1224            fragment: "".to_string(),
1225            line: 1,
1226            column: 1,
1227        });
1228        index.update_file(Path::new("docs/a.md"), file_a);
1229
1230        // Verify B has A as dependent
1231        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1232
1233        // Remove file A
1234        index.remove_file(Path::new("docs/a.md"));
1235
1236        // B should no longer have any dependents
1237        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1238    }
1239
1240    #[test]
1241    fn test_normalize_path() {
1242        // Test .. handling
1243        let path = Path::new("docs/sub/../other.md");
1244        let normalized = WorkspaceIndex::normalize_path(path);
1245        assert_eq!(normalized, PathBuf::from("docs/other.md"));
1246
1247        // Test . handling
1248        let path2 = Path::new("docs/./other.md");
1249        let normalized2 = WorkspaceIndex::normalize_path(path2);
1250        assert_eq!(normalized2, PathBuf::from("docs/other.md"));
1251
1252        // Test multiple ..
1253        let path3 = Path::new("a/b/c/../../d.md");
1254        let normalized3 = WorkspaceIndex::normalize_path(path3);
1255        assert_eq!(normalized3, PathBuf::from("a/d.md"));
1256    }
1257
1258    #[test]
1259    fn test_clear_clears_reverse_deps() {
1260        let mut index = WorkspaceIndex::new();
1261
1262        // File A links to B
1263        let mut file_a = FileIndex::new();
1264        file_a.add_cross_file_link(CrossFileLinkIndex {
1265            target_path: "b.md".to_string(),
1266            fragment: "".to_string(),
1267            line: 1,
1268            column: 1,
1269        });
1270        index.update_file(Path::new("docs/a.md"), file_a);
1271
1272        // Verify B has A as dependent
1273        assert_eq!(index.get_dependents(Path::new("docs/b.md")).len(), 1);
1274
1275        // Clear the index
1276        index.clear();
1277
1278        // Both files and reverse deps should be cleared
1279        assert_eq!(index.file_count(), 0);
1280        assert!(index.get_dependents(Path::new("docs/b.md")).is_empty());
1281    }
1282
1283    #[test]
1284    fn test_is_file_stale() {
1285        let mut index = WorkspaceIndex::new();
1286
1287        // Non-existent file is always stale
1288        assert!(index.is_file_stale(Path::new("nonexistent.md"), "hash123"));
1289
1290        // Add a file with known hash
1291        let file_index = FileIndex::with_hash("hash123".to_string());
1292        index.insert_file(PathBuf::from("docs/test.md"), file_index);
1293
1294        // Same hash means not stale
1295        assert!(!index.is_file_stale(Path::new("docs/test.md"), "hash123"));
1296
1297        // Different hash means stale
1298        assert!(index.is_file_stale(Path::new("docs/test.md"), "different_hash"));
1299    }
1300
1301    #[cfg(feature = "native")]
1302    #[test]
1303    fn test_cache_roundtrip() {
1304        use std::fs;
1305
1306        // Create a temp directory
1307        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_roundtrip");
1308        let _ = fs::remove_dir_all(&temp_dir);
1309        fs::create_dir_all(&temp_dir).unwrap();
1310
1311        // Create an index with some data
1312        let mut index = WorkspaceIndex::new();
1313
1314        let mut file1 = FileIndex::with_hash("abc123".to_string());
1315        file1.add_heading(HeadingIndex {
1316            text: "Test Heading".to_string(),
1317            auto_anchor: "test-heading".to_string(),
1318            custom_anchor: Some("test".to_string()),
1319            line: 1,
1320            is_setext: false,
1321        });
1322        file1.add_cross_file_link(CrossFileLinkIndex {
1323            target_path: "./other.md".to_string(),
1324            fragment: "section".to_string(),
1325            line: 5,
1326            column: 3,
1327        });
1328        index.update_file(Path::new("docs/file1.md"), file1);
1329
1330        let mut file2 = FileIndex::with_hash("def456".to_string());
1331        file2.add_heading(HeadingIndex {
1332            text: "Another Heading".to_string(),
1333            auto_anchor: "another-heading".to_string(),
1334            custom_anchor: None,
1335            line: 1,
1336            is_setext: false,
1337        });
1338        index.update_file(Path::new("docs/other.md"), file2);
1339
1340        // Save to cache
1341        index.save_to_cache(&temp_dir).expect("Failed to save cache");
1342
1343        // Verify cache file exists
1344        assert!(temp_dir.join("workspace_index.bin").exists());
1345
1346        // Load from cache
1347        let loaded = WorkspaceIndex::load_from_cache(&temp_dir).expect("Failed to load cache");
1348
1349        // Verify data matches
1350        assert_eq!(loaded.file_count(), 2);
1351        assert!(loaded.contains_file(Path::new("docs/file1.md")));
1352        assert!(loaded.contains_file(Path::new("docs/other.md")));
1353
1354        // Check file1 details
1355        let file1_loaded = loaded.get_file(Path::new("docs/file1.md")).unwrap();
1356        assert_eq!(file1_loaded.content_hash, "abc123");
1357        assert_eq!(file1_loaded.headings.len(), 1);
1358        assert_eq!(file1_loaded.headings[0].text, "Test Heading");
1359        assert_eq!(file1_loaded.headings[0].custom_anchor, Some("test".to_string()));
1360        assert_eq!(file1_loaded.cross_file_links.len(), 1);
1361        assert_eq!(file1_loaded.cross_file_links[0].target_path, "./other.md");
1362
1363        // Check reverse deps were serialized correctly
1364        let dependents = loaded.get_dependents(Path::new("docs/other.md"));
1365        assert_eq!(dependents.len(), 1);
1366        assert_eq!(dependents[0], PathBuf::from("docs/file1.md"));
1367
1368        // Clean up
1369        let _ = fs::remove_dir_all(&temp_dir);
1370    }
1371
1372    #[cfg(feature = "native")]
1373    #[test]
1374    fn test_cache_missing_file() {
1375        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_missing");
1376        let _ = std::fs::remove_dir_all(&temp_dir);
1377
1378        // Should return None for non-existent cache
1379        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1380        assert!(result.is_none());
1381    }
1382
1383    #[cfg(feature = "native")]
1384    #[test]
1385    fn test_cache_corrupted_file() {
1386        use std::fs;
1387
1388        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_corrupted");
1389        let _ = fs::remove_dir_all(&temp_dir);
1390        fs::create_dir_all(&temp_dir).unwrap();
1391
1392        // Write corrupted data (too small for header)
1393        fs::write(temp_dir.join("workspace_index.bin"), b"bad").unwrap();
1394
1395        // Should return None for corrupted cache (and remove the file)
1396        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1397        assert!(result.is_none());
1398
1399        // Corrupted file should be removed
1400        assert!(!temp_dir.join("workspace_index.bin").exists());
1401
1402        // Clean up
1403        let _ = fs::remove_dir_all(&temp_dir);
1404    }
1405
1406    #[cfg(feature = "native")]
1407    #[test]
1408    fn test_cache_invalid_magic() {
1409        use std::fs;
1410
1411        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_invalid_magic");
1412        let _ = fs::remove_dir_all(&temp_dir);
1413        fs::create_dir_all(&temp_dir).unwrap();
1414
1415        // Write data with wrong magic header
1416        let mut data = Vec::new();
1417        data.extend_from_slice(b"XXXX"); // Wrong magic
1418        data.extend_from_slice(&1u32.to_le_bytes()); // Version 1
1419        data.extend_from_slice(&[0; 100]); // Some garbage data
1420        fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1421
1422        // Should return None for invalid magic
1423        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1424        assert!(result.is_none());
1425
1426        // File should be removed
1427        assert!(!temp_dir.join("workspace_index.bin").exists());
1428
1429        // Clean up
1430        let _ = fs::remove_dir_all(&temp_dir);
1431    }
1432
1433    #[cfg(feature = "native")]
1434    #[test]
1435    fn test_cache_version_mismatch() {
1436        use std::fs;
1437
1438        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_version_mismatch");
1439        let _ = fs::remove_dir_all(&temp_dir);
1440        fs::create_dir_all(&temp_dir).unwrap();
1441
1442        // Write data with correct magic but wrong version
1443        let mut data = Vec::new();
1444        data.extend_from_slice(b"RWSI"); // Correct magic
1445        data.extend_from_slice(&999u32.to_le_bytes()); // Future version
1446        data.extend_from_slice(&[0; 100]); // Some garbage data
1447        fs::write(temp_dir.join("workspace_index.bin"), &data).unwrap();
1448
1449        // Should return None for version mismatch
1450        let result = WorkspaceIndex::load_from_cache(&temp_dir);
1451        assert!(result.is_none());
1452
1453        // File should be removed to trigger rebuild
1454        assert!(!temp_dir.join("workspace_index.bin").exists());
1455
1456        // Clean up
1457        let _ = fs::remove_dir_all(&temp_dir);
1458    }
1459
1460    #[cfg(feature = "native")]
1461    #[test]
1462    fn test_cache_atomic_write() {
1463        use std::fs;
1464
1465        // Test that atomic writes work (no temp files left behind)
1466        let temp_dir = std::env::temp_dir().join("rumdl_test_cache_atomic");
1467        let _ = fs::remove_dir_all(&temp_dir);
1468        fs::create_dir_all(&temp_dir).unwrap();
1469
1470        let index = WorkspaceIndex::new();
1471        index.save_to_cache(&temp_dir).expect("Failed to save");
1472
1473        // Only the final cache file should exist, no temp files
1474        let entries: Vec<_> = fs::read_dir(&temp_dir).unwrap().collect();
1475        assert_eq!(entries.len(), 1);
1476        assert!(temp_dir.join("workspace_index.bin").exists());
1477
1478        // Clean up
1479        let _ = fs::remove_dir_all(&temp_dir);
1480    }
1481
1482    #[test]
1483    fn test_has_anchor_auto_generated() {
1484        let mut file_index = FileIndex::new();
1485        file_index.add_heading(HeadingIndex {
1486            text: "Installation Guide".to_string(),
1487            auto_anchor: "installation-guide".to_string(),
1488            custom_anchor: None,
1489            line: 1,
1490            is_setext: false,
1491        });
1492
1493        // Should find by auto-generated anchor
1494        assert!(file_index.has_anchor("installation-guide"));
1495
1496        // Case-insensitive matching
1497        assert!(file_index.has_anchor("Installation-Guide"));
1498        assert!(file_index.has_anchor("INSTALLATION-GUIDE"));
1499
1500        // Should not find non-existent anchor
1501        assert!(!file_index.has_anchor("nonexistent"));
1502    }
1503
1504    #[test]
1505    fn test_has_anchor_custom() {
1506        let mut file_index = FileIndex::new();
1507        file_index.add_heading(HeadingIndex {
1508            text: "Installation Guide".to_string(),
1509            auto_anchor: "installation-guide".to_string(),
1510            custom_anchor: Some("install".to_string()),
1511            line: 1,
1512            is_setext: false,
1513        });
1514
1515        // Should find by auto-generated anchor
1516        assert!(file_index.has_anchor("installation-guide"));
1517
1518        // Should also find by custom anchor
1519        assert!(file_index.has_anchor("install"));
1520        assert!(file_index.has_anchor("Install")); // case-insensitive
1521
1522        // Should not find non-existent anchor
1523        assert!(!file_index.has_anchor("nonexistent"));
1524    }
1525
1526    #[test]
1527    fn test_get_heading_by_anchor() {
1528        let mut file_index = FileIndex::new();
1529        file_index.add_heading(HeadingIndex {
1530            text: "Installation Guide".to_string(),
1531            auto_anchor: "installation-guide".to_string(),
1532            custom_anchor: Some("install".to_string()),
1533            line: 10,
1534            is_setext: false,
1535        });
1536        file_index.add_heading(HeadingIndex {
1537            text: "Configuration".to_string(),
1538            auto_anchor: "configuration".to_string(),
1539            custom_anchor: None,
1540            line: 20,
1541            is_setext: false,
1542        });
1543
1544        // Get by auto anchor
1545        let heading = file_index.get_heading_by_anchor("installation-guide");
1546        assert!(heading.is_some());
1547        assert_eq!(heading.unwrap().text, "Installation Guide");
1548        assert_eq!(heading.unwrap().line, 10);
1549
1550        // Get by custom anchor
1551        let heading = file_index.get_heading_by_anchor("install");
1552        assert!(heading.is_some());
1553        assert_eq!(heading.unwrap().text, "Installation Guide");
1554
1555        // Get second heading
1556        let heading = file_index.get_heading_by_anchor("configuration");
1557        assert!(heading.is_some());
1558        assert_eq!(heading.unwrap().text, "Configuration");
1559        assert_eq!(heading.unwrap().line, 20);
1560
1561        // Non-existent
1562        assert!(file_index.get_heading_by_anchor("nonexistent").is_none());
1563    }
1564
1565    #[test]
1566    fn test_anchor_lookup_many_headings() {
1567        // Test that O(1) lookup works with many headings
1568        let mut file_index = FileIndex::new();
1569
1570        // Add 100 headings
1571        for i in 0..100 {
1572            file_index.add_heading(HeadingIndex {
1573                text: format!("Heading {i}"),
1574                auto_anchor: format!("heading-{i}"),
1575                custom_anchor: Some(format!("h{i}")),
1576                line: i + 1,
1577                is_setext: false,
1578            });
1579        }
1580
1581        // Verify all can be found
1582        for i in 0..100 {
1583            assert!(file_index.has_anchor(&format!("heading-{i}")));
1584            assert!(file_index.has_anchor(&format!("h{i}")));
1585
1586            let heading = file_index.get_heading_by_anchor(&format!("heading-{i}"));
1587            assert!(heading.is_some());
1588            assert_eq!(heading.unwrap().line, i + 1);
1589        }
1590    }
1591
1592    // =============================================================================
1593    // Tests for extract_cross_file_links utility
1594    // =============================================================================
1595
1596    #[test]
1597    fn test_extract_cross_file_links_basic() {
1598        use crate::config::MarkdownFlavor;
1599
1600        let content = "# Test\n\nSee [link](./other.md) for info.\n";
1601        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1602        let links = extract_cross_file_links(&ctx).relative;
1603
1604        assert_eq!(links.len(), 1);
1605        assert_eq!(links[0].target_path, "./other.md");
1606        assert_eq!(links[0].fragment, "");
1607        assert_eq!(links[0].line, 3);
1608        // "See [link](" = 11 chars, so column 12 is where "./other.md" starts
1609        assert_eq!(links[0].column, 12);
1610    }
1611
1612    #[test]
1613    fn test_extract_cross_file_links_with_fragment() {
1614        use crate::config::MarkdownFlavor;
1615
1616        let content = "Check [guide](./guide.md#install) here.\n";
1617        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1618        let links = extract_cross_file_links(&ctx).relative;
1619
1620        assert_eq!(links.len(), 1);
1621        assert_eq!(links[0].target_path, "./guide.md");
1622        assert_eq!(links[0].fragment, "install");
1623        assert_eq!(links[0].line, 1);
1624        // "Check [guide](" = 14 chars, so column 15 is where "./guide.md" starts
1625        assert_eq!(links[0].column, 15);
1626    }
1627
1628    #[test]
1629    fn test_extract_cross_file_links_multiple_on_same_line() {
1630        use crate::config::MarkdownFlavor;
1631
1632        let content = "See [a](a.md) and [b](b.md) here.\n";
1633        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1634        let links = extract_cross_file_links(&ctx).relative;
1635
1636        assert_eq!(links.len(), 2);
1637
1638        assert_eq!(links[0].target_path, "a.md");
1639        assert_eq!(links[0].line, 1);
1640        // "See [a](" = 8 chars, so column 9
1641        assert_eq!(links[0].column, 9);
1642
1643        assert_eq!(links[1].target_path, "b.md");
1644        assert_eq!(links[1].line, 1);
1645        // "See [a](a.md) and [b](" = 22 chars, so column 23
1646        assert_eq!(links[1].column, 23);
1647    }
1648
1649    #[test]
1650    fn test_extract_cross_file_links_angle_brackets() {
1651        use crate::config::MarkdownFlavor;
1652
1653        let content = "See [link](<path/with (parens).md>) here.\n";
1654        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1655        let links = extract_cross_file_links(&ctx).relative;
1656
1657        assert_eq!(links.len(), 1);
1658        assert_eq!(links[0].target_path, "path/with (parens).md");
1659        assert_eq!(links[0].line, 1);
1660        // "See [link](<" = 12 chars, so column 13
1661        assert_eq!(links[0].column, 13);
1662    }
1663
1664    #[test]
1665    fn test_extract_cross_file_links_skips_external() {
1666        use crate::config::MarkdownFlavor;
1667
1668        let content = r#"
1669[external](https://example.com)
1670[mailto](mailto:test@example.com)
1671[local](./local.md)
1672[fragment](#section)
1673[absolute](/docs/page.md)
1674"#;
1675        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1676        let extracted = extract_cross_file_links(&ctx);
1677
1678        // Only the local markdown link is a directory-relative link.
1679        assert_eq!(extracted.relative.len(), 1);
1680        assert_eq!(extracted.relative[0].target_path, "./local.md");
1681        // The root-relative link is captured separately, leading `/` stripped.
1682        assert_eq!(extracted.root_relative.len(), 1);
1683        assert_eq!(extracted.root_relative[0].target_path, "docs/page.md");
1684    }
1685
1686    #[test]
1687    fn test_extract_cross_file_links_root_relative() {
1688        use crate::config::MarkdownFlavor;
1689
1690        // Root-relative markdown links land in the root_relative bucket with the
1691        // leading `/` stripped; parent traversal and protocol-relative links are
1692        // excluded so they cannot escape a content root.
1693        let content = "[a](/guide.md#install)\n[b](/../escape.md)\n[c](//host/x.md)\n[d](/img/pic.png)\n";
1694        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1695        let extracted = extract_cross_file_links(&ctx);
1696
1697        assert!(extracted.relative.is_empty(), "no directory-relative links here");
1698        assert_eq!(
1699            extracted
1700                .root_relative
1701                .iter()
1702                .map(|l| (l.target_path.as_str(), l.fragment.as_str()))
1703                .collect::<Vec<_>>(),
1704            vec![("guide.md", "install")],
1705            "only the safe root-relative markdown link is captured"
1706        );
1707    }
1708
1709    #[test]
1710    fn test_extract_cross_file_links_skips_non_markdown() {
1711        use crate::config::MarkdownFlavor;
1712
1713        let content = r#"
1714[image](./photo.png)
1715[doc](./readme.md)
1716[pdf](./document.pdf)
1717"#;
1718        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1719        let links = extract_cross_file_links(&ctx).relative;
1720
1721        // Only markdown files are indexed for cross-file validation
1722        assert_eq!(links.len(), 1);
1723        assert_eq!(links[0].target_path, "./readme.md");
1724    }
1725
1726    #[test]
1727    fn test_extract_cross_file_links_skips_code_spans() {
1728        use crate::config::MarkdownFlavor;
1729
1730        let content = "Normal [link](./file.md) and `[code](./ignored.md)` here.\n";
1731        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1732        let links = extract_cross_file_links(&ctx).relative;
1733
1734        // Only the link outside code span should be extracted
1735        assert_eq!(links.len(), 1);
1736        assert_eq!(links[0].target_path, "./file.md");
1737    }
1738
1739    #[test]
1740    fn test_extract_cross_file_links_with_query_params() {
1741        use crate::config::MarkdownFlavor;
1742
1743        let content = "See [doc](./file.md?raw=true) here.\n";
1744        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1745        let links = extract_cross_file_links(&ctx).relative;
1746
1747        assert_eq!(links.len(), 1);
1748        // Query params should be stripped
1749        assert_eq!(links[0].target_path, "./file.md");
1750    }
1751
1752    #[test]
1753    fn test_extract_cross_file_links_empty_content() {
1754        use crate::config::MarkdownFlavor;
1755
1756        let content = "";
1757        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1758        let links = extract_cross_file_links(&ctx).relative;
1759
1760        assert!(links.is_empty());
1761    }
1762
1763    #[test]
1764    fn test_extract_cross_file_links_no_links() {
1765        use crate::config::MarkdownFlavor;
1766
1767        let content = "# Just a heading\n\nSome text without links.\n";
1768        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1769        let links = extract_cross_file_links(&ctx).relative;
1770
1771        assert!(links.is_empty());
1772    }
1773
1774    #[test]
1775    fn test_extract_cross_file_links_position_accuracy_issue_234() {
1776        // This test verifies the fix for GitHub issue #234
1777        // The LSP was reporting incorrect column positions for MD057 diagnostics
1778        use crate::config::MarkdownFlavor;
1779
1780        let content = r#"# Test Document
1781
1782Here is a [broken link](nonexistent-file.md) that should trigger MD057.
1783
1784And another [link](also-missing.md) on this line.
1785"#;
1786        let ctx = LintContext::new(content, MarkdownFlavor::default(), None);
1787        let links = extract_cross_file_links(&ctx).relative;
1788
1789        assert_eq!(links.len(), 2);
1790
1791        // First link: "Here is a [broken link](" = 24 chars, column 25
1792        assert_eq!(links[0].target_path, "nonexistent-file.md");
1793        assert_eq!(links[0].line, 3);
1794        assert_eq!(links[0].column, 25);
1795
1796        // Second link: "And another [link](" = 19 chars, column 20
1797        assert_eq!(links[1].target_path, "also-missing.md");
1798        assert_eq!(links[1].line, 5);
1799        assert_eq!(links[1].column, 20);
1800    }
1801}