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