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