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