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