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