Skip to main content

rumdl_lib/
workspace_index.rs

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