Skip to main content

rumdl_lib/rules/
md057_existing_relative_links.rs

1//!
2//! Rule MD057: Existing relative links
3//!
4//! See [docs/md057.md](../../docs/md057.md) for full documentation, configuration, and examples.
5
6use crate::rule::{
7    CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12    FileIndex, LinkOrigin, Md057LinkTarget, extract_cross_file_links, normalize_relative_path,
13};
14use pulldown_cmark::LinkType;
15use regex::Regex;
16use std::collections::{HashMap, HashSet};
17use std::env;
18use std::path::{Path, PathBuf};
19use std::sync::LazyLock;
20use std::sync::{Arc, Mutex};
21
22mod md057_config;
23use crate::utils::mkdocs_config::resolve_docs_dir;
24use crate::utils::obsidian_config::resolve_attachment_folder;
25use crate::utils::project_root::discover_project_root_from;
26pub use md057_config::{AbsoluteLinksOption, MD057Config};
27
28// Thread-safe cache for file existence checks to avoid redundant filesystem operations
29static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
30    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
31
32// Reset the file existence cache (typically between rule runs)
33fn reset_file_existence_cache() {
34    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
35        cache.clear();
36    }
37}
38
39// Check if a file exists with caching
40fn file_exists_with_cache(path: &Path) -> bool {
41    match FILE_EXISTENCE_CACHE.lock() {
42        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
43        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
44    }
45}
46
47/// Check if a file exists, also trying markdown extensions for extensionless links.
48/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
49fn file_exists_or_markdown_extension(path: &Path) -> bool {
50    resolve_existing_target(path).is_some()
51}
52
53/// The file a link path resolves to, or `None` when nothing is there.
54///
55/// An extensionless link is tried against the markdown extensions in turn, so
56/// `[Link](page)` resolves to `page.md`. Callers that only need existence go
57/// through `file_exists_or_markdown_extension`; the resolved path itself
58/// matters when the answer has to be compared against another file.
59fn resolve_existing_target(path: &Path) -> Option<PathBuf> {
60    // First, check exact path
61    if file_exists_with_cache(path) {
62        return Some(path.to_path_buf());
63    }
64
65    // If the path has no extension, try adding markdown extensions
66    if path.extension().is_none() {
67        for ext in MARKDOWN_EXTENSIONS {
68            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
69            let path_with_ext = path.with_extension(&ext[1..]);
70            if file_exists_with_cache(&path_with_ext) {
71                return Some(path_with_ext);
72            }
73        }
74    }
75
76    None
77}
78
79// Regex to match the start of a link - simplified for performance
80static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
81
82/// Regex to extract the URL from an angle-bracketed markdown link
83/// Format: `](<URL>)` or `](<URL> "title")`
84/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
85static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
86    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
87
88/// Regex to extract the URL from a normal markdown link (without angle brackets)
89/// Format: `](URL)` or `](URL "title")`
90static URL_EXTRACT_REGEX: LazyLock<Regex> =
91    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
92
93/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
94/// Matches: scheme:// or scheme: (per RFC 3986)
95/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
96static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
97    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
98
99// Current working directory
100static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
101
102/// Project root discovered once at process start by walking up from CWD looking
103/// for `.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`. Used as
104/// the anchor for resolving non-absolute paths in `roots` and `search-paths`,
105/// and as the implicit fallback root for absolute-link validation. Tests that
106/// pass a path via `with_path()` bypass this discovery.
107static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
108
109/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
110/// Returns None for non-hex characters.
111#[inline]
112fn hex_digit_to_value(byte: u8) -> Option<u8> {
113    match byte {
114        b'0'..=b'9' => Some(byte - b'0'),
115        b'a'..=b'f' => Some(byte - b'a' + 10),
116        b'A'..=b'F' => Some(byte - b'A' + 10),
117        _ => None,
118    }
119}
120
121/// Supported markdown file extensions
122const MARKDOWN_EXTENSIONS: &[&str] = &[
123    ".md",
124    ".markdown",
125    ".mdx",
126    ".mkd",
127    ".mkdn",
128    ".mdown",
129    ".mdwn",
130    ".qmd",
131    ".rmd",
132];
133
134/// A relative link that resolves to the file it is written in.
135#[derive(Debug, PartialEq, Eq)]
136enum SelfReferentialLink {
137    /// The link addresses the whole file, so there is no shorter way to write
138    /// the same destination.
139    WholeFile,
140    /// The link carries a fragment, which on its own reaches the same heading
141    /// without leaving the page.
142    Fragment(String),
143}
144
145#[cfg(feature = "blake3")]
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147enum DependencyPathState {
148    Missing,
149    File,
150    Directory,
151    Other,
152}
153
154/// Rule MD057: Existing relative links should point to valid files or directories.
155#[derive(Debug, Clone)]
156pub struct MD057ExistingRelativeLinks {
157    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
158    /// so it is shared across clones (the rule is cloned per config group and
159    /// for inline-config overrides); the base is resolved from the file under
160    /// check and consulted while validating that file's links.
161    base_path: Arc<Mutex<Option<PathBuf>>>,
162    /// Configuration for the rule
163    config: MD057Config,
164}
165
166impl Default for MD057ExistingRelativeLinks {
167    fn default() -> Self {
168        Self {
169            base_path: Arc::new(Mutex::new(None)),
170            config: MD057Config::default(),
171        }
172    }
173}
174
175impl MD057ExistingRelativeLinks {
176    /// Create a new instance with default settings
177    pub fn new() -> Self {
178        Self::default()
179    }
180
181    /// Set the base path for resolving relative links
182    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
183        let path = path.as_ref();
184        let dir_path = if path.is_file() {
185            path.parent().map(std::path::Path::to_path_buf)
186        } else {
187            Some(path.to_path_buf())
188        };
189
190        if let Ok(mut guard) = self.base_path.lock() {
191            *guard = dir_path;
192        }
193        self
194    }
195
196    pub fn from_config_struct(config: MD057Config) -> Self {
197        Self {
198            base_path: Arc::new(Mutex::new(None)),
199            config,
200        }
201    }
202
203    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
204    /// against the project root: absolute strings are taken verbatim, relative
205    /// strings are joined onto `project_root`.
206    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
207        if Path::new(path_str).is_absolute() {
208            PathBuf::from(path_str)
209        } else {
210            project_root.join(path_str)
211        }
212    }
213
214    /// Check if a URL is external or should be skipped for validation.
215    ///
216    /// Returns `true` (skip validation) for:
217    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
218    /// - Bare domains: `www.example.com`, `example.com`
219    /// - Email addresses: `user@example.com` (without `mailto:`)
220    /// - Template variables: `{{URL}}`, `{{% include %}}`
221    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
222    ///
223    /// Returns `false` (validate) for:
224    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
225    #[inline]
226    fn is_external_url(&self, url: &str) -> bool {
227        if url.is_empty() {
228            return false;
229        }
230
231        // Quick checks for common external URL patterns
232        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
233            return true;
234        }
235
236        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
237        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
238        if url.starts_with("{{") || url.starts_with("{%") {
239            return true;
240        }
241
242        // Simple check: if URL contains @, it's almost certainly an email address
243        // File paths with @ are extremely rare, so this is a safe heuristic
244        if url.contains('@') {
245            return true; // It's an email address, skip it
246        }
247
248        // Bare domain check (e.g., "example.com")
249        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
250        // Links like [text](nodejs.org/path) without a protocol are broken -
251        // they'll be treated as relative paths by markdown renderers.
252        // Flagging them helps users find missing protocols.
253        // We only skip .com as a minimal safety net for the most common case.
254        // Require the absence of a path separator so a relative file reference
255        // that merely ends in ".com" (e.g. "../../vendor.com") is still
256        // validated rather than assumed to be a bare domain.
257        if !url.contains('/') && url.ends_with(".com") {
258            return true;
259        }
260
261        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
262        // These are not filesystem paths but module/asset aliases
263        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
264        if url.starts_with('~') || url.starts_with('@') {
265            return true;
266        }
267
268        // All other cases (relative paths, etc.) are not external
269        false
270    }
271
272    /// External destinations and gh-aw output placeholders do not name files
273    /// relative to the Markdown source.
274    #[inline]
275    fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
276        self.is_external_url(url)
277            || (flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_output_placeholder(url))
278    }
279
280    /// Check if the URL is a fragment-only link (internal document link)
281    #[inline]
282    fn is_fragment_only_link(&self, url: &str) -> bool {
283        url.starts_with('#')
284    }
285
286    /// Check if the URL is an absolute path (starts with /)
287    /// These are typically routes for published documentation sites.
288    #[inline]
289    fn is_absolute_path(url: &str) -> bool {
290        url.starts_with('/')
291    }
292
293    /// Decode URL percent-encoded sequences in a path.
294    /// Converts `%20` to space, `%2F` to `/`, etc.
295    /// Returns the original string if decoding fails or produces invalid UTF-8.
296    fn url_decode(path: &str) -> String {
297        // Quick check: if no percent sign, return as-is
298        if !path.contains('%') {
299            return path.to_string();
300        }
301
302        let bytes = path.as_bytes();
303        let mut result = Vec::with_capacity(bytes.len());
304        let mut i = 0;
305
306        while i < bytes.len() {
307            if bytes[i] == b'%' && i + 2 < bytes.len() {
308                // Try to parse the two hex digits following %
309                let hex1 = bytes[i + 1];
310                let hex2 = bytes[i + 2];
311                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
312                    result.push(d1 * 16 + d2);
313                    i += 3;
314                    continue;
315                }
316            }
317            result.push(bytes[i]);
318            i += 1;
319        }
320
321        // Convert to UTF-8, falling back to original if invalid
322        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
323    }
324
325    /// Strip query parameters and fragments from a URL for file existence checking.
326    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
327    /// for `path/to/image.png` or `file.md` respectively.
328    ///
329    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
330    /// so we check for `?` first. If a URL has both, only the query is stripped here
331    /// (fragments are handled separately by the regex in `contribute_to_index`).
332    fn strip_query_and_fragment(url: &str) -> &str {
333        // Find the first occurrence of '?' or '#', whichever comes first
334        // This handles both standard URLs (? before #) and edge cases (# before ?)
335        let query_pos = url.find('?');
336        let fragment_pos = url.find('#');
337
338        match (query_pos, fragment_pos) {
339            (Some(q), Some(f)) => {
340                // Both exist - strip at whichever comes first
341                &url[..q.min(f)]
342            }
343            (Some(q), None) => &url[..q],
344            (None, Some(f)) => &url[..f],
345            (None, None) => url,
346        }
347    }
348
349    /// Resolve a relative link against a provided base path
350    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
351        base_path.join(link)
352    }
353
354    /// Compute additional search paths for fallback link resolution.
355    ///
356    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
357    /// with explicitly configured `search-paths`.
358    fn compute_search_paths(
359        &self,
360        flavor: crate::config::MarkdownFlavor,
361        source_file: Option<&Path>,
362        base_path: &Path,
363        project_root: &Path,
364    ) -> Vec<PathBuf> {
365        let mut paths = Vec::new();
366
367        // Auto-detect Obsidian attachment folder
368        if flavor == crate::config::MarkdownFlavor::Obsidian
369            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
370            && attachment_dir != *base_path
371        {
372            paths.push(attachment_dir);
373        }
374
375        // Add explicitly configured search paths. Resolved relative to the
376        // discovered project root so paths are stable regardless of which
377        // subdirectory rumdl is invoked from.
378        for search_path in &self.config.search_paths {
379            let resolved = Self::resolve_against_project_root(search_path, project_root);
380            if resolved != *base_path && !paths.contains(&resolved) {
381                paths.push(resolved);
382            }
383        }
384
385        paths
386    }
387
388    /// Record every filesystem destination this rule may validate.
389    ///
390    /// The index keeps frontmatter inputs regardless of the current rule config
391    /// because a content-matched entry can be reused by another config group.
392    fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
393        if !ctx.links().is_empty() {
394            let lines = ctx.raw_lines();
395            let mut processed_lines = HashSet::new();
396
397            for link in ctx.links() {
398                let line_index = link.line - 1;
399                if line_index >= lines.len()
400                    || ctx
401                        .line_info(link.line)
402                        .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
403                    || !processed_lines.insert(line_index)
404                {
405                    continue;
406                }
407                let line = lines[line_index];
408                if !line.contains("](") {
409                    continue;
410                }
411
412                let line_start_byte = ctx.line_start_byte(link.line).unwrap_or(0);
413                for link_match in LINK_START_REGEX.find_iter(line) {
414                    if link_match.as_str().starts_with('!') {
415                        let escapes = line[..link_match.start()]
416                            .bytes()
417                            .rev()
418                            .take_while(|&byte| byte == b'\\')
419                            .count();
420                        if escapes % 2 == 0 {
421                            continue;
422                        }
423                    }
424
425                    let absolute_start = line_start_byte + link_match.start();
426                    if ctx.is_in_code_span_byte(absolute_start)
427                        || ctx.is_in_math_span(absolute_start)
428                        || ctx.is_in_shortcode(absolute_start)
429                    {
430                        continue;
431                    }
432                    let expected_start = link_match.end() - 1;
433                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, expected_start)
434                        .and_then(|caps| caps.get(1).map(|url| (caps, url)))
435                        .or_else(|| {
436                            extract_url_at(&URL_EXTRACT_REGEX, line, expected_start)
437                                .and_then(|caps| caps.get(1).map(|url| (caps, url)))
438                        });
439                    let Some((_, url_match)) = caps_and_url else {
440                        continue;
441                    };
442                    let url = url_match.as_str().trim();
443                    if url.is_empty()
444                        || (url.starts_with('`') && url.ends_with('`'))
445                        || self.is_non_file_destination(url, ctx.flavor)
446                        || self.is_fragment_only_link(url)
447                    {
448                        continue;
449                    }
450                    index.add_md057_link_target(Md057LinkTarget {
451                        target: url.to_string(),
452                        origin: LinkOrigin::Body,
453                    });
454                }
455            }
456        }
457
458        for image in ctx.images() {
459            if ctx
460                .line_info(image.line)
461                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
462                || matches!(image.link_type, LinkType::WikiLink { .. })
463                || ctx.is_in_shortcode(image.byte_offset)
464            {
465                continue;
466            }
467            let url = image.url.as_ref();
468            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
469                continue;
470            }
471            index.add_md057_link_target(Md057LinkTarget {
472                target: url.to_string(),
473                origin: LinkOrigin::Body,
474            });
475        }
476
477        for reference in ctx.reference_definitions() {
478            if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
479                continue;
480            }
481            let url = reference.url.as_str();
482            if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
483                continue;
484            }
485            index.add_md057_link_target(Md057LinkTarget {
486                target: url.to_string(),
487                origin: LinkOrigin::Body,
488            });
489        }
490
491        for link in frontmatter_values::link_destinations(ctx) {
492            let line = ctx.lines[link.line - 1].content(ctx.content);
493            let url = &line[link.range];
494            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
495                continue;
496            }
497            index.add_md057_link_target(Md057LinkTarget {
498                target: url.to_string(),
499                origin: LinkOrigin::FrontMatter { field: link.field },
500            });
501        }
502    }
503
504    /// Check if a link target exists in any of the additional search paths.
505    fn exists_in_search_paths(
506        decoded_path: &str,
507        search_paths: &[PathBuf],
508        policy: Option<&crate::lint_context::LinkTargetPolicy>,
509    ) -> bool {
510        search_paths.iter().any(|dir| {
511            let candidate = dir.join(decoded_path);
512            Self::target_exists(&candidate, policy)
513        })
514    }
515
516    fn target_exists(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> bool {
517        Self::resolve_target(path, policy).is_some()
518    }
519
520    fn resolve_target(path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> Option<PathBuf> {
521        if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
522            return Some(supplied);
523        }
524        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
525            return None;
526        }
527        resolve_existing_target(path)
528    }
529
530    fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
531        if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
532            format!("Relative link '{url}' target not in the supplied document set")
533        } else {
534            format!("Relative link '{url}' does not exist")
535        }
536    }
537
538    /// Check if a relative link can be compacted and return the simplified form.
539    ///
540    /// Returns `None` if compact-paths is disabled, the link has no traversal,
541    /// or the link is already the shortest form.
542    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
543    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
544        if !self.config.compact_paths {
545            return None;
546        }
547
548        // Split URL into path and suffix (fragment/query)
549        let path_end = url
550            .find('?')
551            .unwrap_or(url.len())
552            .min(url.find('#').unwrap_or(url.len()));
553        let path_part = &url[..path_end];
554        let suffix = &url[path_end..];
555
556        // URL-decode the path portion for filesystem resolution
557        let decoded_path = Self::url_decode(path_part);
558
559        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
560    }
561
562    /// Classify a relative link that points back at the file it is written in.
563    ///
564    /// Returns `None` when the option is off, when the file under check is
565    /// unknown, or when the link addresses anything else. Fragment-only links
566    /// never reach here: they are already the form this reports towards.
567    ///
568    /// The link is resolved against the file's own directory first and then
569    /// against each search path, matching how the existence check resolves it:
570    /// a link that MD057 accepts through a search path is recognized here as
571    /// well, and one that already resolves next to the document is judged
572    /// against that target alone.
573    fn self_referential_link(
574        &self,
575        url: &str,
576        base_path: &Path,
577        search_paths: &[PathBuf],
578        source_file: Option<&Path>,
579        policy: Option<&crate::lint_context::LinkTargetPolicy>,
580    ) -> Option<SelfReferentialLink> {
581        if !self.config.self_referential_links {
582            return None;
583        }
584        let source_file = source_file?;
585
586        let path_part = Self::strip_query_and_fragment(url);
587        if path_part.is_empty() {
588            return None;
589        }
590        let suffix = &url[path_part.len()..];
591
592        let decoded_path = Self::url_decode(path_part);
593        // First hit wins, as it does for the existence check: a target next to
594        // the document is the one the link addresses, and a search path only
595        // answers for a link that resolves nowhere else.
596        let resolved = std::iter::once(base_path)
597            .chain(search_paths.iter().map(PathBuf::as_path))
598            .find_map(|dir| Self::resolve_target(&Self::resolve_link_path_with_base(&decoded_path, dir), policy))?;
599        if !Self::is_same_file(&resolved, source_file) {
600            return None;
601        }
602
603        // A bare fragment reaches the same place. A query string does not
604        // survive being detached from its path, and neither does an empty
605        // fragment, so those are reported without a suggestion.
606        match suffix.strip_prefix('#') {
607            Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
608            _ => Some(SelfReferentialLink::WholeFile),
609        }
610    }
611
612    /// Byte range of a reference definition's destination in the document.
613    ///
614    /// Both the label and the title can repeat the destination text, so the
615    /// search is bounded to what sits between the label's closing bracket and
616    /// the title. Anchoring anywhere else risks a fix rewriting the label,
617    /// which would leave every usage of the reference dangling.
618    fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
619        let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
620        let label_end = Self::label_end(def)?;
621        let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
622            title.saturating_sub(ref_def.byte_offset).min(def.len())
623        });
624        let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
625        let start = ref_def.byte_offset + offset;
626        Some(start..start + ref_def.url.len())
627    }
628
629    /// Offset just past the `]:` that closes a reference definition's label.
630    ///
631    /// A label may itself contain a bracket when the bracket is escaped, so
632    /// escapes are skipped rather than matched.
633    fn label_end(def: &str) -> Option<usize> {
634        let bytes = def.as_bytes();
635        let mut i = 0;
636        while i < bytes.len() {
637            match bytes[i] {
638                b'\\' => i += 2,
639                b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
640                _ => i += 1,
641            }
642        }
643        None
644    }
645
646    /// Whether this document has frontmatter the rule is configured to check.
647    /// Gates the body-link early exits, which would otherwise skip a document
648    /// whose only destinations sit in its frontmatter.
649    fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
650        self.config.check_frontmatter && ctx.front_matter_end_line() > 0
651    }
652
653    /// The warning text for an absolute destination, or `None` when the
654    /// configured handling accepts it.
655    fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
656        match self.config.absolute_links {
657            AbsoluteLinksOption::Ignore => None,
658            AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
659            AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
660            AbsoluteLinksOption::RelativeToRoots => {
661                Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
662            }
663        }
664    }
665
666    /// Report frontmatter values that read as relative destinations but point
667    /// at nothing.
668    ///
669    /// Only existence is checked. The compact-path and self-referential
670    /// suggestions stay out of frontmatter: both rewrite a destination, and a
671    /// frontmatter value is only ever a guess at being one.
672    fn check_front_matter(
673        &self,
674        ctx: &crate::lint_context::LintContext,
675        base_path: &Path,
676        search_paths: &[PathBuf],
677        project_root: &Path,
678        warnings: &mut Vec<LintWarning>,
679    ) {
680        if !self.config.check_frontmatter {
681            return;
682        }
683
684        let ignored: HashSet<String> = self
685            .config
686            .ignore_frontmatter_fields
687            .iter()
688            .map(|field| field.to_lowercase())
689            .collect();
690
691        for link in frontmatter_values::link_destinations(ctx) {
692            if link.field_is_in(&ignored) {
693                continue;
694            }
695
696            let line = ctx.lines[link.line - 1].content(ctx.content);
697            let url = &line[link.range.clone()];
698
699            // A fragment belongs to MD051, which validates it against the
700            // document's own headings.
701            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
702                continue;
703            }
704
705            let column = byte_to_char_count(line, link.range.start);
706            let end_column = column + url.chars().count();
707
708            if Self::is_absolute_path(url) {
709                if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
710                    warnings.push(LintWarning {
711                        rule_name: Some(self.name().to_string()),
712                        line: link.line,
713                        column,
714                        end_line: link.line,
715                        end_column,
716                        message,
717                        severity: Severity::Warning,
718                        fix: None,
719                    });
720                }
721                continue;
722            }
723
724            if Self::relative_target_exists(url, base_path, search_paths, ctx.link_target_policy()) {
725                continue;
726            }
727
728            warnings.push(LintWarning {
729                rule_name: Some(self.name().to_string()),
730                line: link.line,
731                column,
732                end_line: link.line,
733                end_column,
734                message: Self::missing_relative_message(url, ctx.link_target_policy()),
735                severity: Severity::Error,
736                fix: None,
737            });
738        }
739    }
740
741    /// Whether a relative destination resolves to something on disk.
742    ///
743    /// The destination is stripped of its query and fragment, percent-decoded,
744    /// then resolved against `base_path`, with two fallbacks: an `.html`/`.htm`
745    /// target passes when the markdown source it is generated from exists, and
746    /// any target passes when one of `search_paths` holds it.
747    fn relative_target_exists(
748        url: &str,
749        base_path: &Path,
750        search_paths: &[PathBuf],
751        policy: Option<&crate::lint_context::LinkTargetPolicy>,
752    ) -> bool {
753        let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
754        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
755
756        // An extensionless link is also tried with each markdown extension.
757        if Self::target_exists(&resolved_path, policy) {
758            return true;
759        }
760
761        if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
762            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
763            && let (Some(stem), Some(parent)) = (
764                resolved_path.file_stem().and_then(|s| s.to_str()),
765                resolved_path.parent(),
766            )
767            && MARKDOWN_EXTENSIONS
768                .iter()
769                .any(|md_ext| Self::target_exists(&parent.join(format!("{stem}{md_ext}")), policy))
770        {
771            return true;
772        }
773
774        Self::exists_in_search_paths(&decoded_path, search_paths, policy)
775    }
776
777    /// Whether any enabled check can offer a fix. Broken links and absolute
778    /// links are reported without one, so the answer rests on the two options
779    /// that rewrite a destination.
780    fn produces_fixes(&self) -> bool {
781        self.config.compact_paths || self.config.self_referential_links
782    }
783
784    /// The warning text for a link that points at its own file.
785    fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
786        match self_link {
787            SelfReferentialLink::Fragment(fragment) => {
788                format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
789            }
790            SelfReferentialLink::WholeFile => {
791                format!("Relative link '{url}' points to the file it is in")
792            }
793        }
794    }
795
796    /// Whether two existing paths are the same file.
797    ///
798    /// Both sides are canonicalized, which settles symlinks and the platform's
799    /// path representation; the lexical form is the fallback for a path the
800    /// filesystem cannot answer for.
801    fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
802        // Cheap reject before the syscall: a different name is a different file.
803        if resolved.file_name() != source_file.file_name() {
804            return false;
805        }
806        match (resolved.canonicalize(), source_file.canonicalize()) {
807            (Ok(link), Ok(source)) => link == source,
808            _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
809        }
810    }
811
812    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
813    ///
814    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
815    /// Falls back to a generic warning if no mkdocs.yml is found.
816    /// Validate an absolute link against the MkDocs `docs_dir`.
817    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
818        let Some(docs_dir) = resolve_docs_dir(source_path) else {
819            return Some(format!(
820                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
821            ));
822        };
823
824        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
825
826        // MkDocs mode: an extensionless directory link must have index.md.
827        // `require_index_for_dirs = true` enforces this for all directory hits.
828        match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
829            Resolution::Found => None,
830            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
831                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
832                resolved.display()
833            )),
834            Resolution::NotFound { resolved } => Some(format!(
835                "Absolute link '{url}' resolves to '{}' which does not exist",
836                resolved.display()
837            )),
838        }
839    }
840
841    /// Validate an absolute link by resolving it against each configured root and the project root.
842    ///
843    /// Configured `roots` are tried first (first match wins), then the project
844    /// root is tried as an implicit fallback. The fallback supports links
845    /// written as literal absolute paths from the project root (e.g.
846    /// `/content/en/foo.md`) alongside links written relative to a configured
847    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
848    /// emitted only when no root — configured or implicit — contains the target.
849    fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
850        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
851
852        for root in roots {
853            let root_path = Self::resolve_against_project_root(root, project_root);
854            // Filesystem mode: an existing directory without trailing slash is valid.
855            // `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
856            if matches!(
857                Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
858                Resolution::Found
859            ) {
860                return None;
861            }
862        }
863
864        if matches!(
865            // Filesystem mode: see above.
866            Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
867            Resolution::Found
868        ) {
869            return None;
870        }
871
872        let msg = if roots.is_empty() {
873            format!("Absolute link '{url}' was not found under the project root")
874        } else {
875            format!("Absolute link '{url}' was not found under any configured root or the project root")
876        };
877        Some(msg)
878    }
879
880    /// Decode an absolute-link URL into a filesystem-relative path and a
881    /// directory-link flag. Strips the leading `/`, query/fragment suffix, and
882    /// percent-encoding.
883    fn prepare_absolute_url(url: &str) -> (String, bool) {
884        let relative_url = url.trim_start_matches('/');
885        let file_path = Self::strip_query_and_fragment(relative_url);
886        let decoded = Self::url_decode(file_path);
887        let is_directory_link = url.ends_with('/') || decoded.is_empty();
888        (decoded, is_directory_link)
889    }
890
891    /// Try to resolve a decoded absolute-link path under a single root directory.
892    ///
893    /// `require_index_for_dirs` controls how extensionless links that resolve to a
894    /// directory are treated:
895    ///
896    /// - `true` (MkDocs / docs-dir mode): a directory must contain `index.md` to be
897    ///   considered valid, even when the link has no trailing slash. This matches
898    ///   MkDocs' URL routing convention where `/section` serves `section/index.md`.
899    ///
900    /// - `false` (roots / filesystem mode): an existing directory is accepted as a
901    ///   valid target for an extensionless link, matching the behavior of relative
902    ///   links (which use `path.exists()`). Only an explicit trailing-slash link
903    ///   (`is_directory_link == true`) still requires `index.md`.
904    ///
905    /// Applies resolution strategies in order:
906    /// 1. Directory-style links (explicit `/` suffix or `require_index_for_dirs`):
907    ///    look for `<resolved>/index.md`; report `DirectoryWithoutIndex` on failure.
908    /// 2. Filesystem-mode directory hit (`require_index_for_dirs == false` and
909    ///    `is_directory_link == false`): accept the existing directory as `Found`.
910    /// 3. Direct existence (with markdown-extension fallback for extensionless links).
911    /// 4. `.html`/`.htm` links: look for a markdown source with the same stem.
912    fn resolve_under_root_with_opts(
913        root_path: &Path,
914        decoded: &str,
915        is_directory_link: bool,
916        require_index_for_dirs: bool,
917    ) -> Resolution {
918        let resolved = root_path.join(decoded);
919
920        let is_dir = resolved.is_dir();
921
922        // When the link explicitly ends with `/` or the caller requires index.md
923        // for all directory hits (MkDocs mode), apply the stricter check first.
924        // Must be checked before `file_exists_or_markdown_extension` because
925        // `path.exists()` returns `true` for directories.
926        if is_directory_link || (require_index_for_dirs && is_dir) {
927            let index_path = resolved.join("index.md");
928            if file_exists_with_cache(&index_path) {
929                return Resolution::Found;
930            }
931            if is_dir {
932                return Resolution::DirectoryWithoutIndex { resolved };
933            }
934        }
935
936        // Filesystem mode (roots): an existing directory without a trailing slash
937        // is valid — mirrors how relative links accept directories via `path.exists()`.
938        // Exclude decoded paths that end with `/`: a URL like `/guide/#intro` strips
939        // the fragment to `guide/`, so `decoded` carries the trailing slash even though
940        // `is_directory_link` is false (the raw URL ends with `#intro`, not `/`).
941        let decoded_has_trailing_slash = decoded.ends_with('/');
942        if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
943            return Resolution::Found;
944        }
945
946        if file_exists_or_markdown_extension(&resolved) {
947            return Resolution::Found;
948        }
949
950        // For .html/.htm links, accept a matching markdown source in the same
951        // directory — supports doc sites that compile .md to .html.
952        if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
953            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
954            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
955        {
956            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
957                let source_path = parent.join(format!("{stem}{md_ext}"));
958                file_exists_with_cache(&source_path)
959            });
960            if has_md_source {
961                return Resolution::Found;
962            }
963        }
964
965        Resolution::NotFound { resolved }
966    }
967}
968
969/// Cache invalidation for the on-disk lint cache, which only exists in builds
970/// with a filesystem and the `blake3` hasher (native and WASI). The browser build
971/// has neither, so nothing here can be reached from it.
972#[cfg(feature = "blake3")]
973impl MD057ExistingRelativeLinks {
974    /// Fingerprint the filesystem facts that can change this file's MD057 verdict.
975    ///
976    /// The caller supplies path inputs from a content-matched workspace-index
977    /// entry. Resolution stays here so cache validation follows the same config,
978    /// project-root, search-path, and flavor rules as `check`.
979    pub fn cache_dependency_fingerprint(
980        &self,
981        source_file: &Path,
982        flavor: crate::config::MarkdownFlavor,
983        file_index: &FileIndex,
984    ) -> String {
985        let mut hasher = blake3::Hasher::new();
986        hasher.update(b"rumdl-md057-dependencies-v1");
987        if file_index.md057_link_targets.is_empty() {
988            return hasher.finalize().to_hex().to_string();
989        }
990
991        let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
992        let project_root = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
993        let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
994        let base_path = explicit_base.unwrap_or_else(|| {
995            resolved_source
996                .parent()
997                .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
998        });
999        let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
1000        let ignored_frontmatter_fields: HashSet<String> = self
1001            .config
1002            .ignore_frontmatter_fields
1003            .iter()
1004            .map(|field| field.to_lowercase())
1005            .collect();
1006
1007        for dependency in &file_index.md057_link_targets {
1008            if let LinkOrigin::FrontMatter { field } = &dependency.origin
1009                && (!self.config.check_frontmatter
1010                    || field
1011                        .as_ref()
1012                        .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1013            {
1014                continue;
1015            }
1016
1017            let url = dependency.target.as_str();
1018            if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1019                continue;
1020            }
1021
1022            Self::hash_bytes(&mut hasher, url.as_bytes());
1023            if Self::is_absolute_path(url) {
1024                match self.config.absolute_links {
1025                    AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1026                    AbsoluteLinksOption::RelativeToDocs => {
1027                        hasher.update(b"docs");
1028                        if let Some(docs_dir) = resolve_docs_dir(source_file) {
1029                            Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1030                        } else {
1031                            hasher.update(b"no-docs-dir");
1032                        }
1033                    }
1034                    AbsoluteLinksOption::RelativeToRoots => {
1035                        hasher.update(b"roots");
1036                        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1037                        let mut found = false;
1038                        for root in &self.config.roots {
1039                            let root_path = Self::resolve_against_project_root(root, &project_root);
1040                            if Self::observe_under_root(&mut hasher, &root_path, &decoded, is_directory_link, false) {
1041                                found = true;
1042                                break;
1043                            }
1044                        }
1045                        if !found {
1046                            Self::observe_under_root(&mut hasher, &project_root, &decoded, is_directory_link, false);
1047                        }
1048                    }
1049                }
1050            } else {
1051                hasher.update(b"relative");
1052                if self.config.self_referential_links
1053                    && Self::observe_self_referential_resolution(
1054                        &mut hasher,
1055                        url,
1056                        &base_path,
1057                        &search_paths,
1058                        &resolved_source,
1059                    )
1060                {
1061                    continue;
1062                }
1063                Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1064            }
1065        }
1066
1067        hasher.finalize().to_hex().to_string()
1068    }
1069
1070    fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1071        hasher.update(&(bytes.len() as u64).to_le_bytes());
1072        hasher.update(bytes);
1073    }
1074
1075    fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1076        #[cfg(unix)]
1077        {
1078            use std::os::unix::ffi::OsStrExt;
1079            Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1080        }
1081        #[cfg(windows)]
1082        {
1083            use std::os::windows::ffi::OsStrExt;
1084            let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1085            Self::hash_bytes(hasher, &encoded);
1086        }
1087        #[cfg(not(any(unix, windows)))]
1088        Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1089    }
1090
1091    fn observe_path(hasher: &mut blake3::Hasher, path: &Path) -> DependencyPathState {
1092        Self::hash_path(hasher, path);
1093        let state = match std::fs::metadata(path) {
1094            Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1095            Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1096            Ok(_) => DependencyPathState::Other,
1097            Err(_) => DependencyPathState::Missing,
1098        };
1099        hasher.update(&[match state {
1100            DependencyPathState::Missing => 0,
1101            DependencyPathState::File => 1,
1102            DependencyPathState::Directory => 2,
1103            DependencyPathState::Other => 3,
1104        }]);
1105        state
1106    }
1107
1108    fn observe_existing_target(hasher: &mut blake3::Hasher, path: &Path) -> Option<PathBuf> {
1109        if Self::observe_path(hasher, path) != DependencyPathState::Missing {
1110            return Some(path.to_path_buf());
1111        }
1112        if path.extension().is_none() {
1113            for extension in MARKDOWN_EXTENSIONS {
1114                let candidate = path.with_extension(&extension[1..]);
1115                if Self::observe_path(hasher, &candidate) != DependencyPathState::Missing {
1116                    return Some(candidate);
1117                }
1118            }
1119        }
1120        None
1121    }
1122
1123    fn observe_self_referential_resolution(
1124        hasher: &mut blake3::Hasher,
1125        url: &str,
1126        base_path: &Path,
1127        search_paths: &[PathBuf],
1128        source_file: &Path,
1129    ) -> bool {
1130        let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1131        for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1132            let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1133            if let Some(resolved) = Self::observe_existing_target(hasher, &candidate) {
1134                let canonical = resolved.canonicalize().unwrap_or(resolved);
1135                hasher.update(b"resolved-identity");
1136                Self::hash_path(hasher, &canonical);
1137                return Self::is_same_file(&canonical, source_file);
1138            }
1139        }
1140        false
1141    }
1142
1143    fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1144        let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1145        let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1146        if Self::observe_existing_target(hasher, &resolved).is_some() {
1147            return;
1148        }
1149
1150        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1151            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1152            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1153            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1154                Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1155            })
1156        {
1157            return;
1158        }
1159
1160        for search_path in search_paths {
1161            if Self::observe_existing_target(hasher, &search_path.join(&decoded)).is_some() {
1162                return;
1163            }
1164        }
1165    }
1166
1167    fn observe_absolute_resolution(
1168        hasher: &mut blake3::Hasher,
1169        root: &Path,
1170        url: &str,
1171        require_index_for_dirs: bool,
1172    ) -> bool {
1173        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
1174        Self::observe_under_root(hasher, root, &decoded, is_directory_link, require_index_for_dirs)
1175    }
1176
1177    fn observe_under_root(
1178        hasher: &mut blake3::Hasher,
1179        root: &Path,
1180        decoded: &str,
1181        is_directory_link: bool,
1182        require_index_for_dirs: bool,
1183    ) -> bool {
1184        let resolved = root.join(decoded);
1185        let resolved_state = Self::observe_path(hasher, &resolved);
1186        let is_dir = resolved_state == DependencyPathState::Directory;
1187
1188        if is_directory_link || (require_index_for_dirs && is_dir) {
1189            if Self::observe_path(hasher, &resolved.join("index.md")) != DependencyPathState::Missing {
1190                return true;
1191            }
1192            if is_dir {
1193                return false;
1194            }
1195        }
1196
1197        if !require_index_for_dirs && !is_directory_link && !decoded.ends_with('/') && is_dir {
1198            return true;
1199        }
1200        if resolved_state != DependencyPathState::Missing {
1201            return true;
1202        }
1203        if resolved.extension().is_none()
1204            && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1205                Self::observe_path(hasher, &resolved.with_extension(&extension[1..])) != DependencyPathState::Missing
1206            })
1207        {
1208            return true;
1209        }
1210
1211        if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1212            && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1213            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1214        {
1215            return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1216                Self::observe_path(hasher, &parent.join(format!("{stem}{extension}"))) != DependencyPathState::Missing
1217            });
1218        }
1219
1220        false
1221    }
1222}
1223
1224/// Outcome of trying to resolve an absolute link under a single root directory.
1225/// Carries the resolved path on the failure variants so callers can build
1226/// specific error messages without recomputing it.
1227enum Resolution {
1228    Found,
1229    DirectoryWithoutIndex { resolved: PathBuf },
1230    NotFound { resolved: PathBuf },
1231}
1232
1233/// Search `re` in `line` starting at `expected_start`, accepting the match
1234/// only if it begins exactly there.
1235///
1236/// `Regex::captures_at` searches starting at the given offset but does not
1237/// require the match to *begin* there. A bracket's own destination that
1238/// fails to match at its own position (a fragment-only `(#bar)`, an empty
1239/// `()`) would otherwise silently slide forward and return a later,
1240/// unrelated match belonging to a different bracket on the same line.
1241/// Rejecting a non-anchored match treats that case as "no destination for
1242/// this bracket" instead of borrowing a sibling bracket's URL.
1243fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1244    let caps = re.captures_at(line, expected_start)?;
1245    if caps.get(0)?.start() != expected_start {
1246        return None;
1247    }
1248    Some(caps)
1249}
1250
1251impl Rule for MD057ExistingRelativeLinks {
1252    fn name(&self) -> &'static str {
1253        "MD057"
1254    }
1255
1256    fn description(&self) -> &'static str {
1257        "Relative links should point to existing files"
1258    }
1259
1260    fn category(&self) -> RuleCategory {
1261        RuleCategory::Link
1262    }
1263
1264    fn skippable_by_category(&self) -> bool {
1265        // A frontmatter path is a link this rule resolves, and the document
1266        // holding it needs no link syntax anywhere else.
1267        !self.config.check_frontmatter
1268    }
1269
1270    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1271        ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1272    }
1273
1274    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1275        let content = ctx.content;
1276
1277        if content.is_empty() {
1278            return Ok(Vec::new());
1279        }
1280
1281        // Early returns for performance. A document whose only destinations
1282        // sit in its frontmatter has no link syntax to find, so the body-link
1283        // shortcuts only apply when frontmatter is not being checked.
1284        let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1285        if !has_body_links && !self.checks_front_matter_of(ctx) {
1286            return Ok(Vec::new());
1287        }
1288
1289        // Reset the file existence cache for a fresh run
1290        reset_file_existence_cache();
1291
1292        let mut warnings = Vec::new();
1293
1294        // Read the explicit base path (set via `with_path()` in tests) once; it
1295        // doubles as both the per-file base path and the project root override
1296        // for absolute-link resolution.
1297        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1298
1299        // Project root used for absolute-link resolution against configured
1300        // `roots` and as the implicit fallback root. The explicit base wins
1301        // when set; otherwise the discovered project root is used.
1302        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
1303
1304        // The file under check, as the filesystem sees it. Links are compared
1305        // against it to find the ones that point back at their own document.
1306        let self_path: Option<PathBuf> = ctx
1307            .source_file()
1308            .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1309
1310        // Determine base path for resolving relative links.
1311        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
1312        // This ensures each file resolves links relative to its own directory.
1313        let base_path: Option<PathBuf> = {
1314            if explicit_base.is_some() {
1315                explicit_base
1316            } else if let Some(ref resolved_file) = self_path {
1317                // Resolve symlinks to get the actual file location
1318                // This ensures relative links are resolved from the target's directory,
1319                // not the symlink's directory
1320                resolved_file
1321                    .parent()
1322                    .map(std::path::Path::to_path_buf)
1323                    .or_else(|| Some(CURRENT_DIR.clone()))
1324            } else {
1325                // No source file available - cannot validate relative links
1326                None
1327            }
1328        };
1329
1330        // If we still don't have a base path, we can't validate relative links
1331        let Some(base_path) = base_path else {
1332            return Ok(warnings);
1333        };
1334
1335        // Compute additional search paths for fallback link resolution
1336        let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1337
1338        // Use LintContext links instead of expensive regex parsing
1339        if !ctx.links().is_empty() {
1340            // Document source locations preserve offsets across all line ending types.
1341
1342            // Pre-collected lines from context
1343            let lines = ctx.raw_lines();
1344
1345            // Track which lines we've already processed to avoid duplicates
1346            // (ctx.links() may have multiple entries for the same line, especially with malformed markdown)
1347            let mut processed_lines = std::collections::HashSet::new();
1348
1349            for link in ctx.links() {
1350                let line_idx = link.line - 1;
1351                if line_idx >= lines.len() {
1352                    continue;
1353                }
1354
1355                // Skip lines inside PyMdown blocks
1356                if ctx
1357                    .line_info(link.line)
1358                    .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1359                {
1360                    continue;
1361                }
1362
1363                // Skip if we've already processed this line
1364                if !processed_lines.insert(line_idx) {
1365                    continue;
1366                }
1367
1368                let line = lines[line_idx];
1369
1370                // Quick check for link pattern in this line
1371                if !line.contains("](") {
1372                    continue;
1373                }
1374
1375                // Find all links in this line using optimized regex
1376                for link_match in LINK_START_REGEX.find_iter(line) {
1377                    // Skip image syntax (`![...]`) here, images are already fully
1378                    // validated by the dedicated ctx.images() loop below, and processing
1379                    // them again here would duplicate that warning. A bang preceded by
1380                    // an odd number of backslashes is escaped, literal text per
1381                    // CommonMark, making the bracket a normal link that the image loop
1382                    // never sees, so it must stay in this loop.
1383                    if link_match.as_str().starts_with('!') {
1384                        let escapes = line[..link_match.start()]
1385                            .bytes()
1386                            .rev()
1387                            .take_while(|&b| b == b'\\')
1388                            .count();
1389                        if escapes % 2 == 0 {
1390                            continue;
1391                        }
1392                    }
1393
1394                    let start_pos = link_match.start();
1395                    let end_pos = link_match.end();
1396
1397                    // Calculate the absolute position through the document context.
1398                    let line_start_byte = ctx.line_start_byte(line_idx + 1).unwrap_or(0);
1399                    let absolute_start_pos = line_start_byte + start_pos;
1400
1401                    // Skip if this link is in a code span
1402                    if ctx.is_in_code_span_byte(absolute_start_pos) {
1403                        continue;
1404                    }
1405
1406                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
1407                    if ctx.is_in_math_span(absolute_start_pos) {
1408                        continue;
1409                    }
1410
1411                    // Skip if this link is inside a template shortcode tag. The
1412                    // tag is an argument list read by a template, so a path in it
1413                    // is resolved by the site generator's own rules rather than
1414                    // relative to this file.
1415                    if ctx.is_in_shortcode(absolute_start_pos) {
1416                        continue;
1417                    }
1418
1419                    // Find the URL part after the link text
1420                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
1421                    // Then fall back to normal URL regex. Both searches are anchored to
1422                    // this bracket's own position so a destination that cannot match
1423                    // here (fragment-only, empty) yields no URL instead of borrowing
1424                    // the next bracket's destination.
1425                    let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
1426                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1427                        .or_else(|| {
1428                            extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
1429                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
1430                        });
1431
1432                    if let Some((caps, url_group)) = caps_and_url {
1433                        let url = url_group.as_str().trim();
1434
1435                        // Skip empty URLs
1436                        if url.is_empty() {
1437                            continue;
1438                        }
1439
1440                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
1441                        // These are Rust API references, not file paths
1442                        // Example: [`f32::is_subnormal`], [`Vec::push`]
1443                        if url.starts_with('`') && url.ends_with('`') {
1444                            continue;
1445                        }
1446
1447                        // Skip external URLs and fragment-only links
1448                        if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1449                            continue;
1450                        }
1451
1452                        // Handle absolute paths based on config
1453                        if Self::is_absolute_path(url) {
1454                            if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1455                                warnings.push(LintWarning {
1456                                    rule_name: Some(self.name().to_string()),
1457                                    line: link.line,
1458                                    column: byte_to_char_count(line, url_group.start()),
1459                                    end_line: link.line,
1460                                    end_column: byte_to_char_count(line, url_group.end()),
1461                                    message,
1462                                    severity: Severity::Warning,
1463                                    fix: None,
1464                                });
1465                            }
1466                            continue;
1467                        }
1468
1469                        // Check for unnecessary path traversal (compact-paths)
1470                        // Reconstruct full URL including fragment (regex group 2)
1471                        // since url_group (group 1) contains only the path part
1472                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
1473                            format!("{url}{}", frag.as_str())
1474                        } else {
1475                            url.to_string()
1476                        };
1477                        // A link back into the current file. Reported instead of
1478                        // the compaction below, whose shorter path would still
1479                        // be a link the reader should not follow, and instead
1480                        // of the existence check, which this target passes.
1481                        if let Some(self_link) = self.self_referential_link(
1482                            &full_url_for_compact,
1483                            &base_path,
1484                            &extra_search_paths,
1485                            self_path.as_deref(),
1486                            ctx.link_target_policy(),
1487                        ) {
1488                            let url_start = url_group.start();
1489                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1490                            let fix_byte_start = line_start_byte + url_start;
1491                            let fix_byte_end = line_start_byte + url_end;
1492                            warnings.push(LintWarning {
1493                                rule_name: Some(self.name().to_string()),
1494                                line: link.line,
1495                                column: byte_to_char_count(line, url_start),
1496                                end_line: link.line,
1497                                end_column: byte_to_char_count(line, url_end),
1498                                message: Self::self_referential_message(&full_url_for_compact, &self_link),
1499                                severity: Severity::Warning,
1500                                fix: match &self_link {
1501                                    SelfReferentialLink::Fragment(fragment) => {
1502                                        Some(Fix::new(fix_byte_start..fix_byte_end, fragment.clone()))
1503                                    }
1504                                    SelfReferentialLink::WholeFile => None,
1505                                },
1506                            });
1507                            continue;
1508                        }
1509
1510                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
1511                            let url_start = url_group.start();
1512                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
1513                            let fix_byte_start = line_start_byte + url_start;
1514                            let fix_byte_end = line_start_byte + url_end;
1515                            warnings.push(LintWarning {
1516                                rule_name: Some(self.name().to_string()),
1517                                line: link.line,
1518                                column: byte_to_char_count(line, url_start),
1519                                end_line: link.line,
1520                                end_column: byte_to_char_count(line, url_end),
1521                                message: format!(
1522                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
1523                                ),
1524                                severity: Severity::Warning,
1525                                fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1526                            });
1527                        }
1528
1529                        if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy())
1530                        {
1531                            continue;
1532                        }
1533
1534                        // File doesn't exist and no source file found
1535                        // Use actual URL position from regex capture group
1536                        // Note: capture group positions are absolute within the line string
1537                        let url_start = url_group.start();
1538                        let url_end = url_group.end();
1539
1540                        warnings.push(LintWarning {
1541                            rule_name: Some(self.name().to_string()),
1542                            line: link.line,
1543                            column: byte_to_char_count(line, url_start),
1544                            end_line: link.line,
1545                            end_column: byte_to_char_count(line, url_end),
1546                            message: Self::missing_relative_message(url, ctx.link_target_policy()),
1547                            severity: Severity::Error,
1548                            fix: None,
1549                        });
1550                    }
1551                }
1552            }
1553        }
1554
1555        // Also process images - they have URLs already parsed
1556        for image in ctx.images() {
1557            // Skip images inside PyMdown blocks (MkDocs flavor)
1558            if ctx
1559                .line_info(image.line)
1560                .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1561            {
1562                continue;
1563            }
1564
1565            // A wiki embed names a vault entry, not a path relative to this
1566            // file: `![[diagram.png]]` resolves wherever the attachment lives.
1567            // The links loop already leaves `[[diagram.png]]` alone.
1568            if matches!(image.link_type, LinkType::WikiLink { .. }) {
1569                continue;
1570            }
1571
1572            // Image syntax inside a template shortcode tag is a parameter the
1573            // template resolves, not a path relative to this file.
1574            if ctx.is_in_shortcode(image.byte_offset) {
1575                continue;
1576            }
1577
1578            let url = image.url.as_ref();
1579
1580            // Skip empty URLs
1581            if url.is_empty() {
1582                continue;
1583            }
1584
1585            // Skip external URLs and fragment-only links
1586            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1587                continue;
1588            }
1589
1590            // Handle absolute paths based on config
1591            if Self::is_absolute_path(url) {
1592                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1593                    warnings.push(LintWarning {
1594                        rule_name: Some(self.name().to_string()),
1595                        line: image.line,
1596                        column: image.start_col + 1,
1597                        end_line: image.line,
1598                        end_column: image.start_col + 1 + url.chars().count(),
1599                        message,
1600                        severity: Severity::Warning,
1601                        fix: None,
1602                    });
1603                }
1604                continue;
1605            }
1606
1607            // Check for unnecessary path traversal (compact-paths)
1608            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1609                // Find the URL position within the image syntax using document byte offsets.
1610                // Search from image.byte_offset (the `!` character) to locate the URL string.
1611                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1612                    let fix_byte_start = image.byte_offset + url_offset;
1613                    let fix_byte_end = fix_byte_start + url.len();
1614                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1615                });
1616
1617                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1618                let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1619                // The fix range is a document byte offset; the displayed column is
1620                // the corresponding character offset within the line.
1621                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1622                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1623                });
1624                warnings.push(LintWarning {
1625                    rule_name: Some(self.name().to_string()),
1626                    line: image.line,
1627                    column: url_col,
1628                    end_line: image.line,
1629                    end_column: url_col + url.chars().count(),
1630                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1631                    severity: Severity::Warning,
1632                    fix,
1633                });
1634            }
1635
1636            if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1637                continue;
1638            }
1639
1640            // File doesn't exist and no source file found
1641            // Images already have correct position from parser
1642            warnings.push(LintWarning {
1643                rule_name: Some(self.name().to_string()),
1644                line: image.line,
1645                column: image.start_col + 1,
1646                end_line: image.line,
1647                end_column: image.start_col + 1 + url.chars().count(),
1648                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1649                severity: Severity::Error,
1650                fix: None,
1651            });
1652        }
1653
1654        // Also process reference definitions: [ref]: ./path.md
1655        for ref_def in ctx.reference_definitions() {
1656            if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1657                continue;
1658            }
1659            let url = &ref_def.url;
1660
1661            // Skip empty URLs
1662            if url.is_empty() {
1663                continue;
1664            }
1665
1666            // Skip external URLs and fragment-only links
1667            if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1668                continue;
1669            }
1670
1671            // Where this definition's destination sits, shared by every report
1672            // on it. Without a located destination a warning falls back to the
1673            // start of the definition's line and offers no fix.
1674            let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1675            let (line, col) = url_range
1676                .as_ref()
1677                .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1678            let end_col = col + url.chars().count();
1679
1680            // Handle absolute paths based on config
1681            if Self::is_absolute_path(url) {
1682                if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1683                    warnings.push(LintWarning {
1684                        rule_name: Some(self.name().to_string()),
1685                        line,
1686                        column: col,
1687                        end_line: line,
1688                        end_column: end_col,
1689                        message,
1690                        severity: Severity::Warning,
1691                        fix: None,
1692                    });
1693                }
1694                continue;
1695            }
1696
1697            // A definition whose destination is the file holding it.
1698            if let Some(self_link) = self.self_referential_link(
1699                url,
1700                &base_path,
1701                &extra_search_paths,
1702                self_path.as_deref(),
1703                ctx.link_target_policy(),
1704            ) {
1705                warnings.push(LintWarning {
1706                    rule_name: Some(self.name().to_string()),
1707                    line,
1708                    column: col,
1709                    end_line: line,
1710                    end_column: end_col,
1711                    message: Self::self_referential_message(url, &self_link),
1712                    severity: Severity::Warning,
1713                    fix: match (&self_link, &url_range) {
1714                        (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1715                            Some(Fix::new(range.clone(), fragment.clone()))
1716                        }
1717                        _ => None,
1718                    },
1719                });
1720                continue;
1721            }
1722
1723            // Check for unnecessary path traversal (compact-paths)
1724            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1725                warnings.push(LintWarning {
1726                    rule_name: Some(self.name().to_string()),
1727                    line,
1728                    column: col,
1729                    end_line: line,
1730                    end_column: end_col,
1731                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1732                    severity: Severity::Warning,
1733                    fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1734                });
1735            }
1736
1737            if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1738                continue;
1739            }
1740
1741            // File doesn't exist and no source file found
1742            warnings.push(LintWarning {
1743                rule_name: Some(self.name().to_string()),
1744                line,
1745                column: col,
1746                end_line: line,
1747                end_column: end_col,
1748                message: Self::missing_relative_message(url, ctx.link_target_policy()),
1749                severity: Severity::Error,
1750                fix: None,
1751            });
1752        }
1753
1754        self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1755
1756        Ok(warnings)
1757    }
1758
1759    fn fix_capability(&self) -> FixCapability {
1760        if self.produces_fixes() {
1761            FixCapability::ConditionallyFixable
1762        } else {
1763            FixCapability::Unfixable
1764        }
1765    }
1766
1767    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1768        if !self.produces_fixes() {
1769            return Ok(ctx.content.to_string());
1770        }
1771
1772        let warnings = self.check(ctx)?;
1773        let warnings =
1774            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1775        let mut content = ctx.content.to_string();
1776
1777        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1778        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1779        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1780
1781        // Applying fixes right-to-left lets each range stay valid against the
1782        // still-unshifted content to its left. A duplicate or overlapping fix
1783        // would otherwise be applied a second time against content already
1784        // rewritten by an earlier fix, corrupting it; skip any fix whose range
1785        // overlaps the one most recently applied.
1786        let mut last_applied_start: Option<usize> = None;
1787        for fix in fixes {
1788            if let Some(prev_start) = last_applied_start
1789                && fix.range.end > prev_start
1790            {
1791                continue;
1792            }
1793            if fix.range.end <= content.len() {
1794                content.replace_range(fix.range.clone(), &fix.replacement);
1795                last_applied_start = Some(fix.range.start);
1796            }
1797        }
1798
1799        Ok(content)
1800    }
1801
1802    fn as_any(&self) -> &dyn std::any::Any {
1803        self
1804    }
1805
1806    crate::impl_rule_config_sections!(MD057Config);
1807
1808    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1809    where
1810        Self: Sized,
1811    {
1812        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1813        // The flavor is deliberately not captured here: Obsidian attachment-folder
1814        // detection reads `ctx.flavor`, which resolves per file, so a rule built
1815        // once for a workspace still honors a per-file flavor override.
1816        Box::new(Self::from_config_struct(rule_config))
1817    }
1818
1819    fn cross_file_scope(&self) -> CrossFileScope {
1820        CrossFileScope::Workspace
1821    }
1822
1823    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1824        self.contribute_dependency_targets(ctx, index);
1825
1826        // Use the shared utility for cross-file link extraction
1827        // This ensures consistent position tracking between CLI and LSP
1828        let links = extract_cross_file_links(ctx);
1829        for link in links.relative {
1830            index.add_cross_file_link(link);
1831        }
1832        // Root-relative links are not linted, but indexing them keeps the cached
1833        // index complete so the LSP can resolve them for find-references.
1834        for link in links.root_relative {
1835            index.add_root_relative_link(link);
1836        }
1837    }
1838
1839    fn cross_file_check(
1840        &self,
1841        _file_path: &Path,
1842        _file_index: &FileIndex,
1843        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1844    ) -> LintResult {
1845        // All link targets are already validated by check() on each per-file pass.
1846        // check() resolves relative links against the file's own directory, handles
1847        // configured search paths, and applies the absolute_links config.
1848        // Validating them here too would produce identical duplicate warnings for
1849        // every broken link. (#631)
1850        //
1851        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1852        // remains in place to support future cross-file analyses (e.g. heading-anchor
1853        // validation across files).
1854        Ok(Vec::new())
1855    }
1856}
1857
1858/// Compute the shortest relative path from `from_dir` to `to_path`.
1859///
1860/// Both paths must be normalized (no `.` or `..` components).
1861/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1862fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1863    let from_components: Vec<_> = from_dir.components().collect();
1864    let to_components: Vec<_> = to_path.components().collect();
1865
1866    // Find common prefix length
1867    let common_len = from_components
1868        .iter()
1869        .zip(to_components.iter())
1870        .take_while(|(a, b)| a == b)
1871        .count();
1872
1873    let mut result = PathBuf::new();
1874
1875    // Go up for each remaining component in from_dir
1876    for _ in common_len..from_components.len() {
1877        result.push("..");
1878    }
1879
1880    // Append remaining components from to_path
1881    for component in &to_components[common_len..] {
1882        result.push(component);
1883    }
1884
1885    result
1886}
1887
1888/// Check if a relative link path can be shortened.
1889///
1890/// Given the source directory and the raw link path, computes whether there's
1891/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1892/// be simplified, `None` if it's already optimal.
1893fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1894    let link_path = Path::new(raw_link_path);
1895
1896    // Only check paths that contain traversal (../ or ./)
1897    let has_traversal = link_path
1898        .components()
1899        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1900
1901    if !has_traversal {
1902        return None;
1903    }
1904
1905    // Resolve: source_dir + raw_link_path, then normalize
1906    let combined = source_dir.join(link_path);
1907    let normalized_target = normalize_relative_path(&combined);
1908
1909    // Compute shortest path from source_dir back to the normalized target
1910    let normalized_source = normalize_relative_path(source_dir);
1911    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1912
1913    // Compare against the raw link path — if it differs, the path can be compacted
1914    if shortest != link_path {
1915        let compact = shortest.to_string_lossy().to_string();
1916        // Avoid suggesting empty path
1917        if compact.is_empty() {
1918            return None;
1919        }
1920        // Markdown links always use forward slashes regardless of platform
1921        Some(compact.replace('\\', "/"))
1922    } else {
1923        None
1924    }
1925}
1926
1927#[cfg(test)]
1928mod tests {
1929    use super::*;
1930    use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
1931    use std::fs::File;
1932    use std::io::Write;
1933    use tempfile::tempdir;
1934
1935    #[test]
1936    fn test_strip_query_and_fragment() {
1937        // Test query parameter stripping
1938        assert_eq!(
1939            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1940            "file.png"
1941        );
1942        assert_eq!(
1943            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1944            "file.png"
1945        );
1946        assert_eq!(
1947            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1948            "file.png"
1949        );
1950
1951        // Test fragment stripping
1952        assert_eq!(
1953            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1954            "file.md"
1955        );
1956        assert_eq!(
1957            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1958            "file.md"
1959        );
1960
1961        // Test both query and fragment (query comes first, per RFC 3986)
1962        assert_eq!(
1963            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1964            "file.md"
1965        );
1966
1967        // Test no query or fragment
1968        assert_eq!(
1969            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1970            "file.png"
1971        );
1972
1973        // Test with path
1974        assert_eq!(
1975            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1976            "path/to/image.png"
1977        );
1978        assert_eq!(
1979            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1980            "path/to/image.png"
1981        );
1982
1983        // Edge case: fragment before query (non-standard but possible)
1984        assert_eq!(
1985            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1986            "file.md"
1987        );
1988    }
1989
1990    #[test]
1991    fn test_url_decode() {
1992        // Simple space encoding
1993        assert_eq!(
1994            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1995            "penguin with space.jpg"
1996        );
1997
1998        // Path with encoded spaces
1999        assert_eq!(
2000            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
2001            "assets/my file name.png"
2002        );
2003
2004        // Multiple encoded characters
2005        assert_eq!(
2006            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
2007            "hello world!.md"
2008        );
2009
2010        // Lowercase hex
2011        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
2012
2013        // Uppercase hex
2014        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
2015
2016        // Mixed case hex
2017        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
2018
2019        // No encoding - return as-is
2020        assert_eq!(
2021            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
2022            "normal-file.md"
2023        );
2024
2025        // Incomplete percent encoding - leave as-is
2026        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
2027
2028        // Percent at end - leave as-is
2029        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
2030
2031        // Invalid hex digits - leave as-is
2032        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
2033
2034        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
2035        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
2036
2037        // Empty string
2038        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
2039
2040        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
2041        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
2042
2043        // Multiple consecutive encoded characters
2044        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
2045
2046        // Encoded path separators
2047        assert_eq!(
2048            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
2049            "path/to/file.md"
2050        );
2051
2052        // Mixed encoded and non-encoded
2053        assert_eq!(
2054            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
2055            "hello world/foo bar.md"
2056        );
2057
2058        // Special characters that are commonly encoded
2059        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
2060
2061        // Percent at position that looks like encoding but isn't valid
2062        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2063    }
2064
2065    #[test]
2066    fn test_url_encoded_filenames() {
2067        // Create a temporary directory for test files
2068        let temp_dir = tempdir().unwrap();
2069        let base_path = temp_dir.path();
2070
2071        // Create a file with spaces in the name
2072        let file_with_spaces = base_path.join("penguin with space.jpg");
2073        File::create(&file_with_spaces)
2074            .unwrap()
2075            .write_all(b"image data")
2076            .unwrap();
2077
2078        // Create a subdirectory with spaces
2079        let subdir = base_path.join("my images");
2080        std::fs::create_dir(&subdir).unwrap();
2081        let nested_file = subdir.join("photo 1.png");
2082        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2083
2084        // Test content with URL-encoded links
2085        let content = r#"
2086# Test Document with URL-Encoded Links
2087
2088![Penguin](penguin%20with%20space.jpg)
2089![Photo](my%20images/photo%201.png)
2090![Missing](missing%20file.jpg)
2091"#;
2092
2093        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2094
2095        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2096        let result = rule.check(&ctx).unwrap();
2097
2098        // Should only have one warning for the missing file
2099        assert_eq!(
2100            result.len(),
2101            1,
2102            "Should only warn about missing%20file.jpg. Got: {result:?}"
2103        );
2104        assert!(
2105            result[0].message.contains("missing%20file.jpg"),
2106            "Warning should mention the URL-encoded filename"
2107        );
2108    }
2109
2110    #[test]
2111    fn test_external_urls() {
2112        let rule = MD057ExistingRelativeLinks::new();
2113
2114        // Common web protocols
2115        assert!(rule.is_external_url("https://example.com"));
2116        assert!(rule.is_external_url("http://example.com"));
2117        assert!(rule.is_external_url("ftp://example.com"));
2118        assert!(rule.is_external_url("www.example.com"));
2119        assert!(rule.is_external_url("example.com"));
2120
2121        // Special URI schemes
2122        assert!(rule.is_external_url("file:///path/to/file"));
2123        assert!(rule.is_external_url("smb://server/share"));
2124        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2125        assert!(rule.is_external_url("mailto:user@example.com"));
2126        assert!(rule.is_external_url("tel:+1234567890"));
2127        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2128        assert!(rule.is_external_url("javascript:void(0)"));
2129        assert!(rule.is_external_url("ssh://git@github.com/repo"));
2130        assert!(rule.is_external_url("git://github.com/repo.git"));
2131
2132        // Email addresses without mailto: protocol
2133        // These are clearly not file links and should be skipped
2134        assert!(rule.is_external_url("user@example.com"));
2135        assert!(rule.is_external_url("steering@kubernetes.io"));
2136        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2137        assert!(rule.is_external_url("user_name@sub.domain.com"));
2138        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2139
2140        // Template variables should be skipped (not checked as relative links)
2141        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
2142        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
2143        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
2144        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
2145        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
2146        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
2147
2148        // Absolute paths are NOT external (handled separately via is_absolute_path)
2149        // By default they are ignored, but can be configured to warn
2150        assert!(!rule.is_external_url("/api/v1/users"));
2151        assert!(!rule.is_external_url("/blog/2024/release.html"));
2152        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2153        assert!(!rule.is_external_url("/pkg/runtime"));
2154        assert!(!rule.is_external_url("/doc/go1compat"));
2155        assert!(!rule.is_external_url("/index.html"));
2156        assert!(!rule.is_external_url("/assets/logo.png"));
2157
2158        // But is_absolute_path should detect them
2159        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2160        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2161        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2162        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2163        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2164
2165        // Framework path aliases should be skipped (resolved by build tools)
2166        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
2167        assert!(rule.is_external_url("~/assets/image.png"));
2168        assert!(rule.is_external_url("~/components/Button.vue"));
2169        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
2170
2171        // @ prefix (common in Vue, webpack, Vite aliases)
2172        assert!(rule.is_external_url("@/components/Header.vue"));
2173        assert!(rule.is_external_url("@images/photo.jpg"));
2174        assert!(rule.is_external_url("@assets/styles.css"));
2175
2176        // Relative paths should NOT be external (should be validated)
2177        assert!(!rule.is_external_url("./relative/path.md"));
2178        assert!(!rule.is_external_url("relative/path.md"));
2179        assert!(!rule.is_external_url("../parent/path.md"));
2180    }
2181
2182    #[test]
2183    fn test_dot_com_only_skips_bare_domains() {
2184        let rule = MD057ExistingRelativeLinks::new();
2185
2186        // Bare domains ending in .com are treated as external (skipped).
2187        assert!(rule.is_external_url("example.com"));
2188        assert!(rule.is_external_url("sub.example.com"));
2189
2190        // A relative path that merely ends in ".com" must NOT be skipped:
2191        // it contains a path separator, so it is a relative file reference
2192        // that should be validated, not assumed external.
2193        assert!(!rule.is_external_url("../../vendor.com"));
2194        assert!(!rule.is_external_url("./vendor.com"));
2195        assert!(!rule.is_external_url("docs/vendor.com"));
2196    }
2197
2198    #[test]
2199    fn test_framework_path_aliases() {
2200        // Create a temporary directory for test files
2201        let temp_dir = tempdir().unwrap();
2202        let base_path = temp_dir.path();
2203
2204        // Test content with framework path aliases (should all be skipped)
2205        let content = r#"
2206# Framework Path Aliases
2207
2208![Image 1](~/assets/penguin.jpg)
2209![Image 2](~assets/logo.svg)
2210![Image 3](@images/photo.jpg)
2211![Image 4](@/components/icon.svg)
2212[Link](@/pages/about.md)
2213
2214This is a [real missing link](missing.md) that should be flagged.
2215"#;
2216
2217        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2218
2219        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2220        let result = rule.check(&ctx).unwrap();
2221
2222        // Should only have one warning for the real missing link
2223        assert_eq!(
2224            result.len(),
2225            1,
2226            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2227        );
2228        assert!(
2229            result[0].message.contains("missing.md"),
2230            "Warning should be for missing.md"
2231        );
2232    }
2233
2234    #[test]
2235    fn test_url_decode_security_path_traversal() {
2236        // Ensure URL decoding doesn't enable path traversal attacks
2237        // The decoded path is still validated against the base path
2238        let temp_dir = tempdir().unwrap();
2239        let base_path = temp_dir.path();
2240
2241        // Create a file in the temp directory
2242        let file_in_base = base_path.join("safe.md");
2243        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2244
2245        // Test with encoded path traversal attempt
2246        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
2247        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
2248        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
2249        let content = r#"
2250[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2251[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2252[Safe link](safe.md)
2253"#;
2254
2255        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2256
2257        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258        let result = rule.check(&ctx).unwrap();
2259
2260        // The traversal attempts should still be flagged as missing
2261        // (they don't exist relative to base_path after decoding)
2262        assert_eq!(
2263            result.len(),
2264            2,
2265            "Should have warnings for traversal attempts. Got: {result:?}"
2266        );
2267    }
2268
2269    #[test]
2270    fn test_url_encoded_utf8_filenames() {
2271        // Test with actual UTF-8 encoded filenames
2272        let temp_dir = tempdir().unwrap();
2273        let base_path = temp_dir.path();
2274
2275        // Create files with unicode names
2276        let cafe_file = base_path.join("café.md");
2277        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2278
2279        let content = r#"
2280[Café link](caf%C3%A9.md)
2281[Missing unicode](r%C3%A9sum%C3%A9.md)
2282"#;
2283
2284        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2285
2286        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2287        let result = rule.check(&ctx).unwrap();
2288
2289        // Should only warn about the missing file
2290        assert_eq!(
2291            result.len(),
2292            1,
2293            "Should only warn about missing résumé.md. Got: {result:?}"
2294        );
2295        assert!(
2296            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2297            "Warning should mention the URL-encoded filename"
2298        );
2299    }
2300
2301    #[test]
2302    fn test_url_encoded_emoji_filenames() {
2303        // URL-encoded emoji paths should be correctly resolved
2304        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
2305        let temp_dir = tempdir().unwrap();
2306        let base_path = temp_dir.path();
2307
2308        // Create directory with emoji in name: 👤 Personal
2309        let emoji_dir = base_path.join("👤 Personal");
2310        std::fs::create_dir(&emoji_dir).unwrap();
2311
2312        // Create file in that directory: TV Shows.md
2313        let file_path = emoji_dir.join("TV Shows.md");
2314        File::create(&file_path)
2315            .unwrap()
2316            .write_all(b"# TV Shows\n\nContent here.")
2317            .unwrap();
2318
2319        // Test content with URL-encoded emoji link
2320        // %F0%9F%91%A4 = 👤, %20 = space
2321        let content = r#"
2322# Test Document
2323
2324[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2325[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2326"#;
2327
2328        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2329
2330        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331        let result = rule.check(&ctx).unwrap();
2332
2333        // Should only warn about the missing file, not the valid emoji path
2334        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2335        assert!(
2336            result[0].message.contains("Missing.md"),
2337            "Warning should be for Missing.md, got: {}",
2338            result[0].message
2339        );
2340    }
2341
2342    #[test]
2343    fn test_no_warnings_without_base_path() {
2344        let rule = MD057ExistingRelativeLinks::new();
2345        let content = "[Link](missing.md)";
2346
2347        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2348        let result = rule.check(&ctx).unwrap();
2349        assert!(result.is_empty(), "Should have no warnings without base path");
2350    }
2351
2352    #[test]
2353    fn test_existing_and_missing_links() {
2354        // Create a temporary directory for test files
2355        let temp_dir = tempdir().unwrap();
2356        let base_path = temp_dir.path();
2357
2358        // Create an existing file
2359        let exists_path = base_path.join("exists.md");
2360        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2361
2362        // Verify the file exists
2363        assert!(exists_path.exists(), "exists.md should exist for this test");
2364
2365        // Create test content with both existing and missing links
2366        let content = r#"
2367# Test Document
2368
2369[Valid Link](exists.md)
2370[Invalid Link](missing.md)
2371[External Link](https://example.com)
2372[Media Link](image.jpg)
2373        "#;
2374
2375        // Initialize rule with the base path (default: check all files including media)
2376        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2377
2378        // Test the rule
2379        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2380        let result = rule.check(&ctx).unwrap();
2381
2382        // Should have two warnings: missing.md and image.jpg (both don't exist)
2383        assert_eq!(result.len(), 2);
2384        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2385        assert!(messages.iter().any(|m| m.contains("missing.md")));
2386        assert!(messages.iter().any(|m| m.contains("image.jpg")));
2387    }
2388
2389    #[test]
2390    fn test_angle_bracket_links() {
2391        // Create a temporary directory for test files
2392        let temp_dir = tempdir().unwrap();
2393        let base_path = temp_dir.path();
2394
2395        // Create an existing file
2396        let exists_path = base_path.join("exists.md");
2397        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2398
2399        // Create test content with angle bracket links
2400        let content = r#"
2401# Test Document
2402
2403[Valid Link](<exists.md>)
2404[Invalid Link](<missing.md>)
2405[External Link](<https://example.com>)
2406    "#;
2407
2408        // Test with default settings
2409        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2410
2411        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2412        let result = rule.check(&ctx).unwrap();
2413
2414        // Should have one warning for missing.md
2415        assert_eq!(result.len(), 1, "Should have exactly one warning");
2416        assert!(
2417            result[0].message.contains("missing.md"),
2418            "Warning should mention missing.md"
2419        );
2420    }
2421
2422    #[test]
2423    fn test_angle_bracket_links_with_parens() {
2424        // Create a temporary directory for test files
2425        let temp_dir = tempdir().unwrap();
2426        let base_path = temp_dir.path();
2427
2428        // Create directory structure with parentheses in path
2429        let app_dir = base_path.join("app");
2430        std::fs::create_dir(&app_dir).unwrap();
2431        let upload_dir = app_dir.join("(upload)");
2432        std::fs::create_dir(&upload_dir).unwrap();
2433        let page_file = upload_dir.join("page.tsx");
2434        File::create(&page_file)
2435            .unwrap()
2436            .write_all(b"export default function Page() {}")
2437            .unwrap();
2438
2439        // Create test content with angle bracket links containing parentheses
2440        let content = r#"
2441# Test Document with Paths Containing Parens
2442
2443[Upload Page](<app/(upload)/page.tsx>)
2444[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2445[Missing](<app/(missing)/file.md>)
2446"#;
2447
2448        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2449
2450        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2451        let result = rule.check(&ctx).unwrap();
2452
2453        // Should only have one warning for the missing file
2454        assert_eq!(
2455            result.len(),
2456            1,
2457            "Should have exactly one warning for missing file. Got: {result:?}"
2458        );
2459        assert!(
2460            result[0].message.contains("app/(missing)/file.md"),
2461            "Warning should mention app/(missing)/file.md"
2462        );
2463    }
2464
2465    #[test]
2466    fn test_all_file_types_checked() {
2467        // Create a temporary directory for test files
2468        let temp_dir = tempdir().unwrap();
2469        let base_path = temp_dir.path();
2470
2471        // Create a test with various file types - all should be checked
2472        let content = r#"
2473[Image Link](image.jpg)
2474[Video Link](video.mp4)
2475[Markdown Link](document.md)
2476[PDF Link](file.pdf)
2477"#;
2478
2479        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2480
2481        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2482        let result = rule.check(&ctx).unwrap();
2483
2484        // Should warn about all missing files regardless of extension
2485        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2486    }
2487
2488    #[test]
2489    fn test_code_span_detection() {
2490        let rule = MD057ExistingRelativeLinks::new();
2491
2492        // Create a temporary directory for test files
2493        let temp_dir = tempdir().unwrap();
2494        let base_path = temp_dir.path();
2495
2496        let rule = rule.with_path(base_path);
2497
2498        // Test with document structure
2499        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2500
2501        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2502        let result = rule.check(&ctx).unwrap();
2503
2504        // Should only find the real link, not the one in code
2505        assert_eq!(result.len(), 1, "Should only flag the real link");
2506        assert!(result[0].message.contains("nonexistent.md"));
2507    }
2508
2509    #[test]
2510    fn test_inline_code_spans() {
2511        // Create a temporary directory for test files
2512        let temp_dir = tempdir().unwrap();
2513        let base_path = temp_dir.path();
2514
2515        // Create test content with links in inline code spans
2516        let content = r#"
2517# Test Document
2518
2519This is a normal link: [Link](missing.md)
2520
2521This is a code span with a link: `[Link](another-missing.md)`
2522
2523Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2524
2525    "#;
2526
2527        // Initialize rule with the base path
2528        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2529
2530        // Test the rule
2531        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532        let result = rule.check(&ctx).unwrap();
2533
2534        // Should only have warning for the normal link, not for links in code spans
2535        assert_eq!(result.len(), 1, "Should have exactly one warning");
2536        assert!(
2537            result[0].message.contains("missing.md"),
2538            "Warning should be for missing.md"
2539        );
2540        assert!(
2541            !result.iter().any(|w| w.message.contains("another-missing.md")),
2542            "Should not warn about link in code span"
2543        );
2544        assert!(
2545            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2546            "Should not warn about link in inline code"
2547        );
2548    }
2549
2550    #[test]
2551    fn test_extensionless_link_resolution() {
2552        // Create a temporary directory for test files
2553        let temp_dir = tempdir().unwrap();
2554        let base_path = temp_dir.path();
2555
2556        // Create a markdown file WITHOUT specifying .md extension in the link
2557        let page_path = base_path.join("page.md");
2558        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2559
2560        // Test content with extensionless link that should resolve to page.md
2561        let content = r#"
2562# Test Document
2563
2564[Link without extension](page)
2565[Link with extension](page.md)
2566[Missing link](nonexistent)
2567"#;
2568
2569        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2570
2571        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2572        let result = rule.check(&ctx).unwrap();
2573
2574        // Should only have warning for nonexistent link
2575        // Both "page" and "page.md" should resolve to the same file
2576        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2577        assert!(
2578            result[0].message.contains("nonexistent"),
2579            "Warning should be for 'nonexistent' not 'page'"
2580        );
2581    }
2582
2583    // Cross-file validation tests
2584    #[test]
2585    fn test_cross_file_scope() {
2586        let rule = MD057ExistingRelativeLinks::new();
2587        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2588    }
2589
2590    #[test]
2591    fn test_contribute_to_index_extracts_markdown_links() {
2592        let rule = MD057ExistingRelativeLinks::new();
2593        let content = r#"
2594# Document
2595
2596[Link to docs](./docs/guide.md)
2597[Link with fragment](./other.md#section)
2598[External link](https://example.com)
2599[Image link](image.png)
2600[Media file](video.mp4)
2601"#;
2602
2603        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2604        let mut index = FileIndex::new();
2605        rule.contribute_to_index(&ctx, &mut index);
2606
2607        // Should only index markdown file links
2608        assert_eq!(index.cross_file_links.len(), 2);
2609
2610        // Check first link
2611        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2612        assert_eq!(index.cross_file_links[0].fragment, "");
2613
2614        // Check second link (with fragment)
2615        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2616        assert_eq!(index.cross_file_links[1].fragment, "section");
2617    }
2618
2619    #[test]
2620    fn test_contribute_to_index_skips_external_and_anchors() {
2621        let rule = MD057ExistingRelativeLinks::new();
2622        let content = r#"
2623# Document
2624
2625[External](https://example.com)
2626[Another external](http://example.org)
2627[Fragment only](#section)
2628[FTP link](ftp://files.example.com)
2629[Mail link](mailto:test@example.com)
2630[WWW link](www.example.com)
2631"#;
2632
2633        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2634        let mut index = FileIndex::new();
2635        rule.contribute_to_index(&ctx, &mut index);
2636
2637        // Should not index any of these
2638        assert_eq!(index.cross_file_links.len(), 0);
2639    }
2640
2641    #[test]
2642    fn test_cross_file_check_valid_link() {
2643        use crate::workspace_index::WorkspaceIndex;
2644
2645        let rule = MD057ExistingRelativeLinks::new();
2646
2647        // Create a workspace index with the target file
2648        let mut workspace_index = WorkspaceIndex::new();
2649        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2650
2651        // Create file index with a link to an existing file
2652        let mut file_index = FileIndex::new();
2653        file_index.add_cross_file_link(CrossFileLinkIndex {
2654            target_path: "guide.md".to_string(),
2655            fragment: "".to_string(),
2656            line: 5,
2657            column: 1,
2658            origin: LinkOrigin::Body,
2659        });
2660
2661        // Run cross-file check from docs/index.md
2662        let warnings = rule
2663            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2664            .unwrap();
2665
2666        // Should have no warnings - file exists
2667        assert!(warnings.is_empty());
2668    }
2669
2670    #[test]
2671    fn test_cross_file_check_missing_link() {
2672        // cross_file_check delegates all validation to check() to avoid duplicates.
2673        // It always returns empty — the per-file check() path is authoritative.
2674        use crate::workspace_index::WorkspaceIndex;
2675
2676        let rule = MD057ExistingRelativeLinks::new();
2677        let workspace_index = WorkspaceIndex::new();
2678
2679        let mut file_index = FileIndex::new();
2680        file_index.add_cross_file_link(CrossFileLinkIndex {
2681            target_path: "missing.md".to_string(),
2682            fragment: "".to_string(),
2683            line: 5,
2684            column: 1,
2685            origin: LinkOrigin::Body,
2686        });
2687
2688        let warnings = rule
2689            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2690            .unwrap();
2691
2692        // cross_file_check defers to check(); it produces no warnings of its own.
2693        assert!(
2694            warnings.is_empty(),
2695            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2696        );
2697    }
2698
2699    #[test]
2700    fn test_cross_file_check_parent_path() {
2701        use crate::workspace_index::WorkspaceIndex;
2702
2703        let rule = MD057ExistingRelativeLinks::new();
2704
2705        // Create a workspace index with the target file at the root
2706        let mut workspace_index = WorkspaceIndex::new();
2707        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2708
2709        // Create file index with a parent path link
2710        let mut file_index = FileIndex::new();
2711        file_index.add_cross_file_link(CrossFileLinkIndex {
2712            target_path: "../readme.md".to_string(),
2713            fragment: "".to_string(),
2714            line: 5,
2715            column: 1,
2716            origin: LinkOrigin::Body,
2717        });
2718
2719        // Run cross-file check from docs/guide.md
2720        let warnings = rule
2721            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2722            .unwrap();
2723
2724        // Should have no warnings - file exists at normalized path
2725        assert!(warnings.is_empty());
2726    }
2727
2728    #[test]
2729    fn test_cross_file_check_html_link_with_md_source() {
2730        // Test that .html links are accepted when corresponding .md source exists
2731        // This supports mdBook and similar doc generators that compile .md to .html
2732        use crate::workspace_index::WorkspaceIndex;
2733
2734        let rule = MD057ExistingRelativeLinks::new();
2735
2736        // Create a workspace index with the .md source file
2737        let mut workspace_index = WorkspaceIndex::new();
2738        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2739
2740        // Create file index with an .html link (from another rule like MD051)
2741        let mut file_index = FileIndex::new();
2742        file_index.add_cross_file_link(CrossFileLinkIndex {
2743            target_path: "guide.html".to_string(),
2744            fragment: "section".to_string(),
2745            line: 10,
2746            column: 5,
2747            origin: LinkOrigin::Body,
2748        });
2749
2750        // Run cross-file check from docs/index.md
2751        let warnings = rule
2752            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2753            .unwrap();
2754
2755        // Should have no warnings - .md source exists for the .html link
2756        assert!(
2757            warnings.is_empty(),
2758            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2759        );
2760    }
2761
2762    #[test]
2763    fn test_cross_file_check_html_link_without_source() {
2764        // cross_file_check delegates all validation to check() to avoid duplicates.
2765        // Verifying that .html links without a matching .md source are caught is
2766        // already covered by test_html_link_with_md_source (check() path).
2767        use crate::workspace_index::WorkspaceIndex;
2768
2769        let rule = MD057ExistingRelativeLinks::new();
2770        let workspace_index = WorkspaceIndex::new();
2771
2772        let mut file_index = FileIndex::new();
2773        file_index.add_cross_file_link(CrossFileLinkIndex {
2774            target_path: "missing.html".to_string(),
2775            fragment: "".to_string(),
2776            line: 10,
2777            column: 5,
2778            origin: LinkOrigin::Body,
2779        });
2780
2781        let warnings = rule
2782            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2783            .unwrap();
2784
2785        // cross_file_check defers to check(); it produces no warnings of its own.
2786        assert!(
2787            warnings.is_empty(),
2788            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2789        );
2790    }
2791
2792    #[test]
2793    fn test_normalize_path_function() {
2794        // Test simple cases
2795        assert_eq!(
2796            normalize_relative_path(Path::new("docs/guide.md")),
2797            PathBuf::from("docs/guide.md")
2798        );
2799
2800        // Test current directory removal
2801        assert_eq!(
2802            normalize_relative_path(Path::new("./docs/guide.md")),
2803            PathBuf::from("docs/guide.md")
2804        );
2805
2806        // Test parent directory resolution
2807        assert_eq!(
2808            normalize_relative_path(Path::new("docs/sub/../guide.md")),
2809            PathBuf::from("docs/guide.md")
2810        );
2811
2812        // Test multiple parent directories
2813        assert_eq!(
2814            normalize_relative_path(Path::new("a/b/c/../../d.md")),
2815            PathBuf::from("a/d.md")
2816        );
2817    }
2818
2819    #[test]
2820    fn test_html_link_with_md_source() {
2821        // Links to .html files should pass if corresponding .md source exists
2822        let temp_dir = tempdir().unwrap();
2823        let base_path = temp_dir.path();
2824
2825        // Create guide.md (source file)
2826        let md_file = base_path.join("guide.md");
2827        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2828
2829        let content = r#"
2830[Read the guide](guide.html)
2831[Also here](getting-started.html)
2832"#;
2833
2834        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2835        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2836        let result = rule.check(&ctx).unwrap();
2837
2838        // guide.html passes (guide.md exists), getting-started.html fails
2839        assert_eq!(
2840            result.len(),
2841            1,
2842            "Should only warn about missing source. Got: {result:?}"
2843        );
2844        assert!(result[0].message.contains("getting-started.html"));
2845    }
2846
2847    #[test]
2848    fn test_htm_link_with_md_source() {
2849        // .htm extension should also check for markdown source
2850        let temp_dir = tempdir().unwrap();
2851        let base_path = temp_dir.path();
2852
2853        let md_file = base_path.join("page.md");
2854        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2855
2856        let content = "[Page](page.htm)";
2857
2858        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2859        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2860        let result = rule.check(&ctx).unwrap();
2861
2862        assert!(
2863            result.is_empty(),
2864            "Should not warn when .md source exists for .htm link"
2865        );
2866    }
2867
2868    #[test]
2869    fn test_html_link_finds_various_markdown_extensions() {
2870        // Should find .mdx, .markdown, etc. as source files
2871        let temp_dir = tempdir().unwrap();
2872        let base_path = temp_dir.path();
2873
2874        File::create(base_path.join("doc.md")).unwrap();
2875        File::create(base_path.join("tutorial.mdx")).unwrap();
2876        File::create(base_path.join("guide.markdown")).unwrap();
2877
2878        let content = r#"
2879[Doc](doc.html)
2880[Tutorial](tutorial.html)
2881[Guide](guide.html)
2882"#;
2883
2884        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2885        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2886        let result = rule.check(&ctx).unwrap();
2887
2888        assert!(
2889            result.is_empty(),
2890            "Should find all markdown variants as source files. Got: {result:?}"
2891        );
2892    }
2893
2894    #[test]
2895    fn test_html_link_in_subdirectory() {
2896        // Should find markdown source in subdirectories
2897        let temp_dir = tempdir().unwrap();
2898        let base_path = temp_dir.path();
2899
2900        let docs_dir = base_path.join("docs");
2901        std::fs::create_dir(&docs_dir).unwrap();
2902        File::create(docs_dir.join("guide.md"))
2903            .unwrap()
2904            .write_all(b"# Guide")
2905            .unwrap();
2906
2907        let content = "[Guide](docs/guide.html)";
2908
2909        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2910        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2911        let result = rule.check(&ctx).unwrap();
2912
2913        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2914    }
2915
2916    #[test]
2917    fn test_absolute_path_skipped_in_check() {
2918        // Test that absolute paths are skipped during link validation
2919        // This fixes the bug where /pkg/runtime was being flagged
2920        let temp_dir = tempdir().unwrap();
2921        let base_path = temp_dir.path();
2922
2923        let content = r#"
2924# Test Document
2925
2926[Go Runtime](/pkg/runtime)
2927[Go Runtime with Fragment](/pkg/runtime#section)
2928[API Docs](/api/v1/users)
2929[Blog Post](/blog/2024/release.html)
2930[React Hook](/react/hooks/use-state.html)
2931"#;
2932
2933        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2934        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2935        let result = rule.check(&ctx).unwrap();
2936
2937        // Should have NO warnings - all absolute paths should be skipped
2938        assert!(
2939            result.is_empty(),
2940            "Absolute paths should be skipped. Got warnings: {result:?}"
2941        );
2942    }
2943
2944    #[test]
2945    fn test_absolute_path_skipped_in_cross_file_check() {
2946        // Test that absolute paths are skipped in cross_file_check()
2947        use crate::workspace_index::WorkspaceIndex;
2948
2949        let rule = MD057ExistingRelativeLinks::new();
2950
2951        // Create an empty workspace index (no files exist)
2952        let workspace_index = WorkspaceIndex::new();
2953
2954        // Create file index with absolute path links (should be skipped)
2955        let mut file_index = FileIndex::new();
2956        file_index.add_cross_file_link(CrossFileLinkIndex {
2957            target_path: "/pkg/runtime.md".to_string(),
2958            fragment: "".to_string(),
2959            line: 5,
2960            column: 1,
2961            origin: LinkOrigin::Body,
2962        });
2963        file_index.add_cross_file_link(CrossFileLinkIndex {
2964            target_path: "/api/v1/users.md".to_string(),
2965            fragment: "section".to_string(),
2966            line: 10,
2967            column: 1,
2968            origin: LinkOrigin::Body,
2969        });
2970
2971        // Run cross-file check
2972        let warnings = rule
2973            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2974            .unwrap();
2975
2976        // Should have NO warnings - absolute paths should be skipped
2977        assert!(
2978            warnings.is_empty(),
2979            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2980        );
2981    }
2982
2983    #[test]
2984    fn test_protocol_relative_url_not_skipped() {
2985        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2986        // They should still be caught by is_external_url() though
2987        let temp_dir = tempdir().unwrap();
2988        let base_path = temp_dir.path();
2989
2990        let content = r#"
2991# Test Document
2992
2993[External](//example.com/page)
2994[Another](//cdn.example.com/asset.js)
2995"#;
2996
2997        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2998        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2999        let result = rule.check(&ctx).unwrap();
3000
3001        // Should have NO warnings - protocol-relative URLs are external and should be skipped
3002        assert!(
3003            result.is_empty(),
3004            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3005        );
3006    }
3007
3008    #[test]
3009    fn test_email_addresses_skipped() {
3010        // Test that email addresses without mailto: are skipped
3011        // These are clearly not file links (the @ symbol is definitive)
3012        let temp_dir = tempdir().unwrap();
3013        let base_path = temp_dir.path();
3014
3015        let content = r#"
3016# Test Document
3017
3018[Contact](user@example.com)
3019[Steering](steering@kubernetes.io)
3020[Support](john.doe+filter@company.co.uk)
3021[User](user_name@sub.domain.com)
3022"#;
3023
3024        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3025        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3026        let result = rule.check(&ctx).unwrap();
3027
3028        // Should have NO warnings - email addresses are clearly not file links and should be skipped
3029        assert!(
3030            result.is_empty(),
3031            "Email addresses should be skipped. Got warnings: {result:?}"
3032        );
3033    }
3034
3035    #[test]
3036    fn test_email_addresses_vs_file_paths() {
3037        // Test that email addresses (anything with @) are skipped
3038        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
3039        let temp_dir = tempdir().unwrap();
3040        let base_path = temp_dir.path();
3041
3042        let content = r#"
3043# Test Document
3044
3045[Email](user@example.com)  <!-- Should be skipped (email) -->
3046[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
3047[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
3048"#;
3049
3050        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3051        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3052        let result = rule.check(&ctx).unwrap();
3053
3054        // All should be skipped - anything with @ is treated as an email
3055        assert!(
3056            result.is_empty(),
3057            "All email addresses should be skipped. Got: {result:?}"
3058        );
3059    }
3060
3061    #[test]
3062    fn test_diagnostic_position_accuracy() {
3063        // Test that diagnostics point to the URL, not the link text
3064        let temp_dir = tempdir().unwrap();
3065        let base_path = temp_dir.path();
3066
3067        // Position markers:     0         1         2         3
3068        //                       0123456789012345678901234567890123456789
3069        let content = "prefix [text](missing.md) suffix";
3070        //             The URL "missing.md" starts at 0-indexed position 14
3071        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
3072
3073        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3074        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3075        let result = rule.check(&ctx).unwrap();
3076
3077        assert_eq!(result.len(), 1, "Should have exactly one warning");
3078        assert_eq!(result[0].line, 1, "Should be on line 1");
3079        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3080        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3081    }
3082
3083    #[test]
3084    fn test_diagnostic_position_non_ascii_link() {
3085        // Issue #670: columns are character offsets, not byte offsets. The CJK
3086        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
3087        let temp_dir = tempdir().unwrap();
3088        let base_path = temp_dir.path();
3089
3090        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
3091        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
3092        // and ends past character column 21, i.e. end_column 22.
3093        let content = "你好你好[你好](not-exist.md) bar";
3094
3095        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3096        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3097        let result = rule.check(&ctx).unwrap();
3098
3099        assert_eq!(result.len(), 1, "Should have exactly one warning");
3100        assert_eq!(result[0].line, 1, "Should be on line 1");
3101        assert_eq!(
3102            result[0].column, 10,
3103            "Column must be a character offset, not a byte offset"
3104        );
3105        assert_eq!(result[0].end_column, 22, "End column must be character-based");
3106    }
3107
3108    #[test]
3109    fn test_diagnostic_position_angle_brackets() {
3110        // Test position accuracy with angle bracket links
3111        let temp_dir = tempdir().unwrap();
3112        let base_path = temp_dir.path();
3113
3114        // Position markers:     0         1         2
3115        //                       012345678901234567890
3116        let content = "[link](<missing.md>)";
3117        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
3118
3119        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3120        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121        let result = rule.check(&ctx).unwrap();
3122
3123        assert_eq!(result.len(), 1, "Should have exactly one warning");
3124        assert_eq!(result[0].line, 1, "Should be on line 1");
3125        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3126    }
3127
3128    #[test]
3129    fn test_diagnostic_position_multiline() {
3130        // Test that line numbers are correct for links on different lines
3131        let temp_dir = tempdir().unwrap();
3132        let base_path = temp_dir.path();
3133
3134        let content = r#"# Title
3135Some text on line 2
3136[link on line 3](missing1.md)
3137More text
3138[link on line 5](missing2.md)"#;
3139
3140        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3141        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3142        let result = rule.check(&ctx).unwrap();
3143
3144        assert_eq!(result.len(), 2, "Should have two warnings");
3145
3146        // First warning should be on line 3
3147        assert_eq!(result[0].line, 3, "First warning should be on line 3");
3148        assert!(result[0].message.contains("missing1.md"));
3149
3150        // Second warning should be on line 5
3151        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3152        assert!(result[1].message.contains("missing2.md"));
3153    }
3154
3155    #[test]
3156    fn test_diagnostic_position_with_spaces() {
3157        // Test position with URLs that have spaces in parentheses
3158        let temp_dir = tempdir().unwrap();
3159        let base_path = temp_dir.path();
3160
3161        let content = "[link]( missing.md )";
3162        //             0123456789012345678901
3163        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
3164        //             which is 1-indexed column 9
3165
3166        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3167        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3168        let result = rule.check(&ctx).unwrap();
3169
3170        assert_eq!(result.len(), 1, "Should have exactly one warning");
3171        // The regex captures the URL without leading/trailing spaces
3172        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3173    }
3174
3175    #[test]
3176    fn test_diagnostic_position_image() {
3177        // Test that image diagnostics also have correct positions
3178        let temp_dir = tempdir().unwrap();
3179        let base_path = temp_dir.path();
3180
3181        let content = "![alt text](missing.jpg)";
3182
3183        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3184        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3185        let result = rule.check(&ctx).unwrap();
3186
3187        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3188        assert_eq!(result[0].line, 1);
3189        // Images use start_col from the parser, which should point to the URL
3190        assert!(result[0].column > 0, "Should have valid column position");
3191        assert!(result[0].message.contains("missing.jpg"));
3192    }
3193
3194    #[test]
3195    fn test_diagnostic_position_non_ascii_image() {
3196        // Issue #670: image columns are character offsets, not byte offsets.
3197        let temp_dir = tempdir().unwrap();
3198        let base_path = temp_dir.path();
3199
3200        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
3201        // The image syntax starts at the '!' which is 1-indexed character column 5.
3202        let content = "你好你好![你好](not-exist.png)";
3203
3204        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3205        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3206        let result = rule.check(&ctx).unwrap();
3207
3208        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3209        assert_eq!(result[0].line, 1, "Should be on line 1");
3210        assert_eq!(
3211            result[0].column, 5,
3212            "Column must be a character offset, not a byte offset"
3213        );
3214        assert!(result[0].message.contains("not-exist.png"));
3215    }
3216
3217    #[test]
3218    fn test_diagnostic_position_non_ascii_reference_def() {
3219        // Issue #670: reference-definition columns are character offsets. A
3220        // multi-byte label shifts the URL's byte offset away from its character
3221        // column.
3222        let temp_dir = tempdir().unwrap();
3223        let base_path = temp_dir.path();
3224
3225        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
3226        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
3227        let content = "[你好]: not-exist.md";
3228
3229        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3230        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3231        let result = rule.check(&ctx).unwrap();
3232
3233        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3234        assert_eq!(result[0].line, 1, "Should be on line 1");
3235        assert_eq!(
3236            result[0].column, 7,
3237            "Column must be a character offset, not a byte offset"
3238        );
3239        assert_eq!(result[0].end_column, 19, "End column must be character-based");
3240    }
3241
3242    #[test]
3243    fn test_wikilinks_skipped() {
3244        // Wikilinks should not trigger MD057 warnings
3245        // They use a different linking system (e.g., Obsidian, wiki software)
3246        let temp_dir = tempdir().unwrap();
3247        let base_path = temp_dir.path();
3248
3249        let content = r#"# Test Document
3250
3251[[Microsoft#Windows OS]]
3252[[SomePage]]
3253[[Page With Spaces]]
3254[[path/to/page#section]]
3255[[page|Display Text]]
3256
3257This is a [real missing link](missing.md) that should be flagged.
3258"#;
3259
3260        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3261        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3262        let result = rule.check(&ctx).unwrap();
3263
3264        // Should only warn about the regular markdown link, not wikilinks
3265        assert_eq!(
3266            result.len(),
3267            1,
3268            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3269        );
3270        assert!(
3271            result[0].message.contains("missing.md"),
3272            "Warning should be for missing.md, not wikilinks"
3273        );
3274    }
3275
3276    #[test]
3277    fn test_wiki_embeds_skipped() {
3278        // A wiki embed names a vault entry, not a path relative to this file,
3279        // so `![[diagram.png]]` is not a missing relative link even though no
3280        // such file sits next to the document.
3281        let temp_dir = tempdir().unwrap();
3282        let base_path = temp_dir.path();
3283
3284        let content = r#"# Test Document
3285
3286![[diagram.png]]
3287![[subfolder/diagram.png]]
3288![[diagram.png|300]]
3289![[Some Note]]
3290
3291This is a [real missing link](missing.md) that should be flagged.
3292"#;
3293
3294        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3295        for flavor in [
3296            crate::config::MarkdownFlavor::Obsidian,
3297            crate::config::MarkdownFlavor::Standard,
3298        ] {
3299            let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3300            let result = rule.check(&ctx).unwrap();
3301
3302            assert_eq!(
3303                result.len(),
3304                1,
3305                "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3306            );
3307            assert!(result[0].message.contains("missing.md"));
3308        }
3309    }
3310
3311    #[test]
3312    fn test_wikilinks_not_added_to_index() {
3313        // Wikilinks should not be added to the cross-file link index
3314        let temp_dir = tempdir().unwrap();
3315        let base_path = temp_dir.path();
3316
3317        let content = r#"# Test Document
3318
3319[[Microsoft#Windows OS]]
3320[[SomePage#section]]
3321[Regular Link](other.md)
3322"#;
3323
3324        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3325        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3326
3327        let mut file_index = FileIndex::new();
3328        rule.contribute_to_index(&ctx, &mut file_index);
3329
3330        // Should only have the regular markdown link (if it's a markdown file)
3331        // Wikilinks should not be added
3332        let cross_file_links = &file_index.cross_file_links;
3333        assert_eq!(
3334            cross_file_links.len(),
3335            1,
3336            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3337        );
3338        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3339    }
3340
3341    #[test]
3342    fn test_reference_definition_missing_file() {
3343        // Reference definitions [ref]: ./path.md should be checked
3344        let temp_dir = tempdir().unwrap();
3345        let base_path = temp_dir.path();
3346
3347        let content = r#"# Test Document
3348
3349[test]: ./missing.md
3350[example]: ./nonexistent.html
3351
3352Use [test] and [example] here.
3353"#;
3354
3355        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3356        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3357        let result = rule.check(&ctx).unwrap();
3358
3359        // Should have warnings for both reference definitions
3360        assert_eq!(
3361            result.len(),
3362            2,
3363            "Should have warnings for missing reference definition targets. Got: {result:?}"
3364        );
3365        assert!(
3366            result.iter().any(|w| w.message.contains("missing.md")),
3367            "Should warn about missing.md"
3368        );
3369        assert!(
3370            result.iter().any(|w| w.message.contains("nonexistent.html")),
3371            "Should warn about nonexistent.html"
3372        );
3373    }
3374
3375    #[test]
3376    fn test_reference_definition_existing_file() {
3377        // Reference definitions to existing files should NOT trigger warnings
3378        let temp_dir = tempdir().unwrap();
3379        let base_path = temp_dir.path();
3380
3381        // Create an existing file
3382        let exists_path = base_path.join("exists.md");
3383        File::create(&exists_path)
3384            .unwrap()
3385            .write_all(b"# Existing file")
3386            .unwrap();
3387
3388        let content = r#"# Test Document
3389
3390[test]: ./exists.md
3391
3392Use [test] here.
3393"#;
3394
3395        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3396        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3397        let result = rule.check(&ctx).unwrap();
3398
3399        // Should have NO warnings since the file exists
3400        assert!(
3401            result.is_empty(),
3402            "Should not warn about existing file. Got: {result:?}"
3403        );
3404    }
3405
3406    #[test]
3407    fn test_reference_definition_external_url_skipped() {
3408        // Reference definitions with external URLs should be skipped
3409        let temp_dir = tempdir().unwrap();
3410        let base_path = temp_dir.path();
3411
3412        let content = r#"# Test Document
3413
3414[google]: https://google.com
3415[example]: http://example.org
3416[mail]: mailto:test@example.com
3417[ftp]: ftp://files.example.com
3418[local]: ./missing.md
3419
3420Use [google], [example], [mail], [ftp], [local] here.
3421"#;
3422
3423        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3424        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3425        let result = rule.check(&ctx).unwrap();
3426
3427        // Should only warn about the local missing file, not external URLs
3428        assert_eq!(
3429            result.len(),
3430            1,
3431            "Should only warn about local missing file. Got: {result:?}"
3432        );
3433        assert!(
3434            result[0].message.contains("missing.md"),
3435            "Warning should be for missing.md"
3436        );
3437    }
3438
3439    #[test]
3440    fn test_reference_definition_fragment_only_skipped() {
3441        // Reference definitions with fragment-only URLs should be skipped
3442        let temp_dir = tempdir().unwrap();
3443        let base_path = temp_dir.path();
3444
3445        let content = r#"# Test Document
3446
3447[section]: #my-section
3448
3449Use [section] here.
3450"#;
3451
3452        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3453        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3454        let result = rule.check(&ctx).unwrap();
3455
3456        // Should have NO warnings for fragment-only links
3457        assert!(
3458            result.is_empty(),
3459            "Should not warn about fragment-only reference. Got: {result:?}"
3460        );
3461    }
3462
3463    #[test]
3464    fn test_reference_definition_column_position() {
3465        // Test that column position points to the URL in the reference definition
3466        let temp_dir = tempdir().unwrap();
3467        let base_path = temp_dir.path();
3468
3469        // Position markers:     0         1         2
3470        //                       0123456789012345678901
3471        let content = "[ref]: ./missing.md";
3472        //             The URL "./missing.md" starts at 0-indexed position 7
3473        //             which is 1-indexed column 8
3474
3475        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3476        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3477        let result = rule.check(&ctx).unwrap();
3478
3479        assert_eq!(result.len(), 1, "Should have exactly one warning");
3480        assert_eq!(result[0].line, 1, "Should be on line 1");
3481        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3482    }
3483
3484    #[test]
3485    fn test_reference_definition_html_with_md_source() {
3486        // Reference definitions to .html files should pass if corresponding .md source exists
3487        let temp_dir = tempdir().unwrap();
3488        let base_path = temp_dir.path();
3489
3490        // Create guide.md (source file)
3491        let md_file = base_path.join("guide.md");
3492        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3493
3494        let content = r#"# Test Document
3495
3496[guide]: ./guide.html
3497[missing]: ./missing.html
3498
3499Use [guide] and [missing] here.
3500"#;
3501
3502        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3503        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3504        let result = rule.check(&ctx).unwrap();
3505
3506        // guide.html passes (guide.md exists), missing.html fails
3507        assert_eq!(
3508            result.len(),
3509            1,
3510            "Should only warn about missing source. Got: {result:?}"
3511        );
3512        assert!(result[0].message.contains("missing.html"));
3513    }
3514
3515    #[test]
3516    fn test_reference_definition_url_encoded() {
3517        // Reference definitions with URL-encoded paths should be decoded before checking
3518        let temp_dir = tempdir().unwrap();
3519        let base_path = temp_dir.path();
3520
3521        // Create a file with spaces in the name
3522        let file_with_spaces = base_path.join("file with spaces.md");
3523        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3524
3525        let content = r#"# Test Document
3526
3527[spaces]: ./file%20with%20spaces.md
3528[missing]: ./missing%20file.md
3529
3530Use [spaces] and [missing] here.
3531"#;
3532
3533        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3534        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3535        let result = rule.check(&ctx).unwrap();
3536
3537        // Should only warn about the missing file
3538        assert_eq!(
3539            result.len(),
3540            1,
3541            "Should only warn about missing URL-encoded file. Got: {result:?}"
3542        );
3543        assert!(result[0].message.contains("missing%20file.md"));
3544    }
3545
3546    #[test]
3547    fn test_inline_and_reference_both_checked() {
3548        // Both inline links and reference definitions should be checked
3549        let temp_dir = tempdir().unwrap();
3550        let base_path = temp_dir.path();
3551
3552        let content = r#"# Test Document
3553
3554[inline link](./inline-missing.md)
3555[ref]: ./ref-missing.md
3556
3557Use [ref] here.
3558"#;
3559
3560        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3561        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3562        let result = rule.check(&ctx).unwrap();
3563
3564        // Should warn about both the inline link and the reference definition
3565        assert_eq!(
3566            result.len(),
3567            2,
3568            "Should warn about both inline and reference links. Got: {result:?}"
3569        );
3570        assert!(
3571            result.iter().any(|w| w.message.contains("inline-missing.md")),
3572            "Should warn about inline-missing.md"
3573        );
3574        assert!(
3575            result.iter().any(|w| w.message.contains("ref-missing.md")),
3576            "Should warn about ref-missing.md"
3577        );
3578    }
3579
3580    #[test]
3581    fn test_footnote_definitions_not_flagged() {
3582        // Regression test for issue #286: footnote definitions should not be
3583        // treated as reference definitions and flagged as broken links
3584        let rule = MD057ExistingRelativeLinks::default();
3585
3586        let content = r#"# Title
3587
3588A footnote[^1].
3589
3590[^1]: [link](https://www.google.com).
3591"#;
3592
3593        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3594        let result = rule.check(&ctx).unwrap();
3595
3596        assert!(
3597            result.is_empty(),
3598            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3599        );
3600    }
3601
3602    #[test]
3603    fn test_footnote_with_relative_link_inside() {
3604        // Footnotes containing relative links should not be checked
3605        // (the footnote content is not a URL, it's content that may contain links)
3606        let rule = MD057ExistingRelativeLinks::default();
3607
3608        let content = r#"# Title
3609
3610See the footnote[^1].
3611
3612[^1]: Check out [this file](./existing.md) for more info.
3613[^2]: Also see [missing](./does-not-exist.md).
3614"#;
3615
3616        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3617        let result = rule.check(&ctx).unwrap();
3618
3619        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3620        // but the footnote definition itself should not be treated as a reference definition
3621        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3622        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3623        for warning in &result {
3624            assert!(
3625                !warning.message.contains("[this file]"),
3626                "Footnote content should not be treated as URL: {warning:?}"
3627            );
3628            assert!(
3629                !warning.message.contains("[missing]"),
3630                "Footnote content should not be treated as URL: {warning:?}"
3631            );
3632        }
3633    }
3634
3635    #[test]
3636    fn test_mixed_footnotes_and_reference_definitions() {
3637        // Ensure regular reference definitions are still checked while footnotes are skipped
3638        let temp_dir = tempdir().unwrap();
3639        let base_path = temp_dir.path();
3640
3641        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3642
3643        let content = r#"# Title
3644
3645A footnote[^1] and a [ref link][myref].
3646
3647[^1]: This is a footnote with [link](https://example.com).
3648
3649[myref]: ./missing-file.md "This should be checked"
3650"#;
3651
3652        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3653        let result = rule.check(&ctx).unwrap();
3654
3655        // Should only warn about the regular reference definition, not the footnote
3656        assert_eq!(
3657            result.len(),
3658            1,
3659            "Should only warn about the regular reference definition. Got: {result:?}"
3660        );
3661        assert!(
3662            result[0].message.contains("missing-file.md"),
3663            "Should warn about missing-file.md in reference definition"
3664        );
3665    }
3666
3667    #[test]
3668    fn test_absolute_links_ignore_by_default() {
3669        // By default, absolute links are ignored (not validated)
3670        let temp_dir = tempdir().unwrap();
3671        let base_path = temp_dir.path();
3672
3673        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3674
3675        let content = r#"# Links
3676
3677[API docs](/api/v1/users)
3678[Blog post](/blog/2024/release.html)
3679![Logo](/assets/logo.png)
3680
3681[ref]: /docs/reference.md
3682"#;
3683
3684        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3685        let result = rule.check(&ctx).unwrap();
3686
3687        // No warnings - absolute links are ignored by default
3688        assert!(
3689            result.is_empty(),
3690            "Absolute links should be ignored by default. Got: {result:?}"
3691        );
3692    }
3693
3694    #[test]
3695    fn test_absolute_links_warn_config() {
3696        // When configured to warn, absolute links should generate warnings
3697        let temp_dir = tempdir().unwrap();
3698        let base_path = temp_dir.path();
3699
3700        let config = MD057Config {
3701            absolute_links: AbsoluteLinksOption::Warn,
3702            ..Default::default()
3703        };
3704        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3705
3706        let content = r#"# Links
3707
3708[API docs](/api/v1/users)
3709[Blog post](/blog/2024/release.html)
3710"#;
3711
3712        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3713        let result = rule.check(&ctx).unwrap();
3714
3715        // Should have 2 warnings for the 2 absolute links
3716        assert_eq!(
3717            result.len(),
3718            2,
3719            "Should warn about both absolute links. Got: {result:?}"
3720        );
3721        assert!(
3722            result[0].message.contains("cannot be validated locally"),
3723            "Warning should explain why: {}",
3724            result[0].message
3725        );
3726        assert!(
3727            result[0].message.contains("/api/v1/users"),
3728            "Warning should include the link path"
3729        );
3730    }
3731
3732    #[test]
3733    fn test_absolute_links_warn_images() {
3734        // Images with absolute paths should also warn when configured
3735        let temp_dir = tempdir().unwrap();
3736        let base_path = temp_dir.path();
3737
3738        let config = MD057Config {
3739            absolute_links: AbsoluteLinksOption::Warn,
3740            ..Default::default()
3741        };
3742        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3743
3744        let content = r#"# Images
3745
3746![Logo](/assets/logo.png)
3747"#;
3748
3749        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3750        let result = rule.check(&ctx).unwrap();
3751
3752        assert_eq!(
3753            result.len(),
3754            1,
3755            "Should warn about absolute image path. Got: {result:?}"
3756        );
3757        assert!(
3758            result[0].message.contains("/assets/logo.png"),
3759            "Warning should include the image path"
3760        );
3761    }
3762
3763    #[test]
3764    fn test_absolute_links_warn_reference_definitions() {
3765        // Reference definitions with absolute paths should also warn when configured
3766        let temp_dir = tempdir().unwrap();
3767        let base_path = temp_dir.path();
3768
3769        let config = MD057Config {
3770            absolute_links: AbsoluteLinksOption::Warn,
3771            ..Default::default()
3772        };
3773        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3774
3775        let content = r#"# Reference
3776
3777See the [docs][ref].
3778
3779[ref]: /docs/reference.md
3780"#;
3781
3782        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3783        let result = rule.check(&ctx).unwrap();
3784
3785        assert_eq!(
3786            result.len(),
3787            1,
3788            "Should warn about absolute reference definition. Got: {result:?}"
3789        );
3790        assert!(
3791            result[0].message.contains("/docs/reference.md"),
3792            "Warning should include the reference path"
3793        );
3794    }
3795
3796    #[test]
3797    fn test_search_paths_inline_link() {
3798        let temp_dir = tempdir().unwrap();
3799        let base_path = temp_dir.path();
3800
3801        // Create an "assets" directory with an image
3802        let assets_dir = base_path.join("assets");
3803        std::fs::create_dir_all(&assets_dir).unwrap();
3804        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3805
3806        let config = MD057Config {
3807            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3808            ..Default::default()
3809        };
3810        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3811
3812        let content = "# Test\n\n[Photo](photo.png)\n";
3813        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3814        let result = rule.check(&ctx).unwrap();
3815
3816        assert!(
3817            result.is_empty(),
3818            "Should find photo.png via search-paths. Got: {result:?}"
3819        );
3820    }
3821
3822    #[test]
3823    fn test_search_paths_image() {
3824        let temp_dir = tempdir().unwrap();
3825        let base_path = temp_dir.path();
3826
3827        let assets_dir = base_path.join("attachments");
3828        std::fs::create_dir_all(&assets_dir).unwrap();
3829        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3830
3831        let config = MD057Config {
3832            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3833            ..Default::default()
3834        };
3835        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3836
3837        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3838        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3839        let result = rule.check(&ctx).unwrap();
3840
3841        assert!(
3842            result.is_empty(),
3843            "Should find diagram.svg via search-paths. Got: {result:?}"
3844        );
3845    }
3846
3847    #[test]
3848    fn test_search_paths_reference_definition() {
3849        let temp_dir = tempdir().unwrap();
3850        let base_path = temp_dir.path();
3851
3852        let assets_dir = base_path.join("images");
3853        std::fs::create_dir_all(&assets_dir).unwrap();
3854        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3855
3856        let config = MD057Config {
3857            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3858            ..Default::default()
3859        };
3860        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3861
3862        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3863        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3864        let result = rule.check(&ctx).unwrap();
3865
3866        assert!(
3867            result.is_empty(),
3868            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3869        );
3870    }
3871
3872    #[test]
3873    fn test_search_paths_still_warns_when_truly_missing() {
3874        let temp_dir = tempdir().unwrap();
3875        let base_path = temp_dir.path();
3876
3877        let assets_dir = base_path.join("assets");
3878        std::fs::create_dir_all(&assets_dir).unwrap();
3879
3880        let config = MD057Config {
3881            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3882            ..Default::default()
3883        };
3884        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3885
3886        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3887        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3888        let result = rule.check(&ctx).unwrap();
3889
3890        assert_eq!(
3891            result.len(),
3892            1,
3893            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3894        );
3895    }
3896
3897    #[test]
3898    fn test_search_paths_nonexistent_directory() {
3899        let temp_dir = tempdir().unwrap();
3900        let base_path = temp_dir.path();
3901
3902        let config = MD057Config {
3903            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3904            ..Default::default()
3905        };
3906        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3907
3908        let content = "# Test\n\n![Missing](photo.png)\n";
3909        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3910        let result = rule.check(&ctx).unwrap();
3911
3912        assert_eq!(
3913            result.len(),
3914            1,
3915            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3916        );
3917    }
3918
3919    #[test]
3920    fn test_obsidian_attachment_folder_named() {
3921        let temp_dir = tempdir().unwrap();
3922        let vault = temp_dir.path().join("vault");
3923        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3924        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3925        std::fs::create_dir_all(vault.join("notes")).unwrap();
3926
3927        std::fs::write(
3928            vault.join(".obsidian/app.json"),
3929            r#"{"attachmentFolderPath": "Attachments"}"#,
3930        )
3931        .unwrap();
3932        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3933
3934        let notes_dir = vault.join("notes");
3935        let source_file = notes_dir.join("test.md");
3936        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3937
3938        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3939
3940        let content = "# Test\n\n![Photo](photo.png)\n";
3941        let ctx =
3942            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3943        let result = rule.check(&ctx).unwrap();
3944
3945        assert!(
3946            result.is_empty(),
3947            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3948        );
3949    }
3950
3951    #[test]
3952    fn test_obsidian_attachment_same_folder_as_file() {
3953        let temp_dir = tempdir().unwrap();
3954        let vault = temp_dir.path().join("vault-rf");
3955        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3956        std::fs::create_dir_all(vault.join("notes")).unwrap();
3957
3958        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3959
3960        // Image in the same directory as the file — default behavior, no extra search needed
3961        let notes_dir = vault.join("notes");
3962        let source_file = notes_dir.join("test.md");
3963        std::fs::write(&source_file, "placeholder").unwrap();
3964        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3965
3966        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3967
3968        let content = "# Test\n\n![Photo](photo.png)\n";
3969        let ctx =
3970            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3971        let result = rule.check(&ctx).unwrap();
3972
3973        assert!(
3974            result.is_empty(),
3975            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3976        );
3977    }
3978
3979    #[test]
3980    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3981        let temp_dir = tempdir().unwrap();
3982        let vault = temp_dir.path().join("vault-nf");
3983        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3984        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3985        std::fs::create_dir_all(vault.join("notes")).unwrap();
3986
3987        std::fs::write(
3988            vault.join(".obsidian/app.json"),
3989            r#"{"attachmentFolderPath": "Attachments"}"#,
3990        )
3991        .unwrap();
3992        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3993
3994        let notes_dir = vault.join("notes");
3995        let source_file = notes_dir.join("test.md");
3996        std::fs::write(&source_file, "placeholder").unwrap();
3997
3998        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3999
4000        let content = "# Test\n\n![Photo](photo.png)\n";
4001        // Standard flavor — NOT Obsidian
4002        let ctx =
4003            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4004        let result = rule.check(&ctx).unwrap();
4005
4006        assert_eq!(
4007            result.len(),
4008            1,
4009            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4010        );
4011    }
4012
4013    #[test]
4014    fn test_search_paths_combined_with_obsidian() {
4015        let temp_dir = tempdir().unwrap();
4016        let vault = temp_dir.path().join("vault-combo");
4017        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4018        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4019        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4020        std::fs::create_dir_all(vault.join("notes")).unwrap();
4021
4022        std::fs::write(
4023            vault.join(".obsidian/app.json"),
4024            r#"{"attachmentFolderPath": "Attachments"}"#,
4025        )
4026        .unwrap();
4027        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4028        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4029
4030        let notes_dir = vault.join("notes");
4031        let source_file = notes_dir.join("test.md");
4032        std::fs::write(&source_file, "placeholder").unwrap();
4033
4034        let extra_assets_dir = vault.join("extra-assets");
4035        let config = MD057Config {
4036            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4037            ..Default::default()
4038        };
4039        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
4040
4041        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
4042        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
4043        let ctx =
4044            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4045        let result = rule.check(&ctx).unwrap();
4046
4047        assert!(
4048            result.is_empty(),
4049            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4050        );
4051    }
4052
4053    #[test]
4054    fn test_obsidian_attachment_subfolder_under_file() {
4055        let temp_dir = tempdir().unwrap();
4056        let vault = temp_dir.path().join("vault-sub");
4057        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4058        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4059
4060        std::fs::write(
4061            vault.join(".obsidian/app.json"),
4062            r#"{"attachmentFolderPath": "./assets"}"#,
4063        )
4064        .unwrap();
4065        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4066
4067        let notes_dir = vault.join("notes");
4068        let source_file = notes_dir.join("test.md");
4069        std::fs::write(&source_file, "placeholder").unwrap();
4070
4071        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4072
4073        let content = "# Test\n\n![Photo](photo.png)\n";
4074        let ctx =
4075            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4076        let result = rule.check(&ctx).unwrap();
4077
4078        assert!(
4079            result.is_empty(),
4080            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4081        );
4082    }
4083
4084    #[test]
4085    fn test_obsidian_attachment_vault_root() {
4086        let temp_dir = tempdir().unwrap();
4087        let vault = temp_dir.path().join("vault-root");
4088        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4089        std::fs::create_dir_all(vault.join("notes")).unwrap();
4090
4091        // Empty string = vault root
4092        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4093        std::fs::write(vault.join("photo.png"), "fake").unwrap();
4094
4095        let notes_dir = vault.join("notes");
4096        let source_file = notes_dir.join("test.md");
4097        std::fs::write(&source_file, "placeholder").unwrap();
4098
4099        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
4100
4101        let content = "# Test\n\n![Photo](photo.png)\n";
4102        let ctx =
4103            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4104        let result = rule.check(&ctx).unwrap();
4105
4106        assert!(
4107            result.is_empty(),
4108            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4109        );
4110    }
4111
4112    #[test]
4113    fn test_search_paths_multiple_directories() {
4114        let temp_dir = tempdir().unwrap();
4115        let base_path = temp_dir.path();
4116
4117        let dir_a = base_path.join("dir-a");
4118        let dir_b = base_path.join("dir-b");
4119        std::fs::create_dir_all(&dir_a).unwrap();
4120        std::fs::create_dir_all(&dir_b).unwrap();
4121        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4122        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4123
4124        let config = MD057Config {
4125            search_paths: vec![
4126                dir_a.to_string_lossy().into_owned(),
4127                dir_b.to_string_lossy().into_owned(),
4128            ],
4129            ..Default::default()
4130        };
4131        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4132
4133        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
4134        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4135        let result = rule.check(&ctx).unwrap();
4136
4137        assert!(
4138            result.is_empty(),
4139            "Should find files across multiple search paths. Got: {result:?}"
4140        );
4141    }
4142
4143    /// MD057 validates every link target in `check()`, so its `cross_file_check`
4144    /// deliberately reports nothing: emitting there too would double every broken
4145    /// link warning.
4146    ///
4147    /// The target here does not exist anywhere the rule would look, so a
4148    /// `cross_file_check` that started resolving paths would report it and fail
4149    /// this test. The paired `check()` call is the positive control proving the
4150    /// link really is broken, which is what keeps the empty result meaningful.
4151    #[test]
4152    fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4153        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4154
4155        let temp_dir = tempdir().unwrap();
4156        let base_path = temp_dir.path();
4157
4158        let file_path = base_path.join("README.md");
4159        let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4160        std::fs::write(&file_path, content).unwrap();
4161
4162        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4163
4164        let ctx = crate::lint_context::LintContext::new(
4165            content,
4166            crate::config::MarkdownFlavor::Standard,
4167            Some(file_path.clone()),
4168        );
4169        let per_file = rule.check(&ctx).unwrap();
4170        assert_eq!(
4171            per_file.len(),
4172            1,
4173            "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4174        );
4175
4176        let mut file_index = FileIndex::default();
4177        file_index.cross_file_links.push(CrossFileLinkIndex {
4178            target_path: "missing-guide.md".to_string(),
4179            fragment: String::new(),
4180            line: 3,
4181            column: 1,
4182            origin: LinkOrigin::Body,
4183        });
4184
4185        let result = rule
4186            .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4187            .unwrap();
4188
4189        assert!(
4190            result.is_empty(),
4191            "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4192        );
4193    }
4194
4195    #[test]
4196    fn test_check_clears_stale_cache() {
4197        // Verify that check() resets the file existence cache so stale entries from
4198        // a previous lint cycle do not suppress valid warnings.
4199        let temp_dir = tempdir().unwrap();
4200        let base_path = temp_dir.path();
4201
4202        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4203
4204        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
4205        let phantom_path = base_path.join("phantom.md");
4206        {
4207            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4208            cache.insert(phantom_path.clone(), true);
4209        }
4210
4211        let content = "[phantom](phantom.md)\n";
4212        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4213        let warnings = rule.check(&ctx).unwrap();
4214
4215        // check() must reset the cache; stale "exists=true" must not suppress the warning.
4216        assert_eq!(
4217            warnings.len(),
4218            1,
4219            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4220        );
4221        assert!(warnings[0].message.contains("phantom.md"));
4222    }
4223
4224    #[test]
4225    fn test_check_does_not_carry_over_cache_between_runs() {
4226        // Two consecutive check() calls should each start with a fresh cache.
4227        let temp_dir = tempdir().unwrap();
4228        let base_path = temp_dir.path();
4229
4230        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4231
4232        let content = "[missing](nonexistent.md)\n";
4233        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4234
4235        // First run: file doesn't exist — warning expected.
4236        let warnings_1 = rule.check(&ctx).unwrap();
4237        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4238
4239        // Inject a stale "exists = true" entry for the resolved path.
4240        let nonexistent_path = base_path.join("nonexistent.md");
4241        {
4242            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4243            cache.insert(nonexistent_path.clone(), true);
4244        }
4245
4246        // Second run: cache says file exists, but check() should reset it first.
4247        let warnings_2 = rule.check(&ctx).unwrap();
4248        assert_eq!(
4249            warnings_2.len(),
4250            1,
4251            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4252        );
4253    }
4254
4255    // --- Bug #631: duplicate warnings for broken relative links ---
4256
4257    /// Regression test: a single broken relative link must produce exactly one
4258    /// warning across both check() and cross_file_check(). Previously, each
4259    /// code path emitted an identical warning independently, causing duplicates.
4260    #[test]
4261    fn test_no_duplicate_warnings_for_broken_relative_link() {
4262        use crate::workspace_index::WorkspaceIndex;
4263
4264        let temp_dir = tempdir().unwrap();
4265        let base_path = temp_dir.path();
4266
4267        // The broken link target does NOT exist on disk.
4268        let source_file = base_path.join("index.md");
4269        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4270
4271        let content = "[broken](does/not/exist.md)\n";
4272
4273        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4274
4275        // Collect warnings from check() (per-file path)
4276        let ctx = crate::lint_context::LintContext::new(
4277            content,
4278            crate::config::MarkdownFlavor::Standard,
4279            Some(source_file.clone()),
4280        );
4281        let check_warnings = rule.check(&ctx).unwrap();
4282
4283        // Collect warnings from cross_file_check() (workspace-index path)
4284        let mut file_index = FileIndex::new();
4285        rule.contribute_to_index(&ctx, &mut file_index);
4286        let workspace_index = WorkspaceIndex::new();
4287        let cross_warnings = rule
4288            .cross_file_check(&source_file, &file_index, &workspace_index)
4289            .unwrap();
4290
4291        let total = check_warnings.len() + cross_warnings.len();
4292        assert_eq!(
4293            total, 1,
4294            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4295             check={check_warnings:?}, cross={cross_warnings:?}"
4296        );
4297    }
4298
4299    // --- Bug #632: absolute directory links incorrectly flagged ---
4300
4301    /// With absolute-links = "relative_to_roots", links to existing targets must
4302    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
4303    #[test]
4304    fn test_absolute_dir_link_accepted_relative_to_roots() {
4305        let temp_dir = tempdir().unwrap();
4306        let root = temp_dir.path();
4307
4308        // Create directory `d` with a file inside (but no index.md)
4309        let dir_d = root.join("d");
4310        std::fs::create_dir_all(&dir_d).unwrap();
4311        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4312
4313        // Content exercises all four matrix cells:
4314        //   relative file, relative dir, absolute file, absolute dir
4315        let content = "\
4316[absolute dir](/d)\n\
4317[relative dir](d)\n\
4318[absolute file](/d/foo.md)\n\
4319[relative file](d/foo.md)\n";
4320
4321        let config = MD057Config {
4322            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4323            roots: vec![],
4324            ..Default::default()
4325        };
4326        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4327
4328        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4329        let result = rule.check(&ctx).unwrap();
4330
4331        assert!(
4332            result.is_empty(),
4333            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4334        );
4335    }
4336
4337    /// A directory link with a trailing slash and no index.md should be reported
4338    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
4339    #[test]
4340    fn test_absolute_trailing_slash_dir_link_requires_index() {
4341        let temp_dir = tempdir().unwrap();
4342        let root = temp_dir.path();
4343
4344        // Create directory `d` WITHOUT index.md
4345        let dir_d = root.join("d");
4346        std::fs::create_dir_all(&dir_d).unwrap();
4347        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4348
4349        // Trailing slash signals "this is a directory index" — index.md must exist.
4350        let content = "[dir with slash](/d/)\n";
4351
4352        let config = MD057Config {
4353            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4354            roots: vec![],
4355            ..Default::default()
4356        };
4357        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4358
4359        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4360        let result = rule.check(&ctx).unwrap();
4361
4362        assert_eq!(
4363            result.len(),
4364            1,
4365            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
4366        );
4367    }
4368
4369    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
4370    /// is absent. This is tested via the full check() path with RelativeToDocs config
4371    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
4372    #[test]
4373    fn test_docs_dir_variant_still_enforces_index_md() {
4374        let temp_dir = tempdir().unwrap();
4375        let root = temp_dir.path();
4376
4377        // Create a minimal mkdocs.yml pointing at a "docs" directory
4378        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4379
4380        // Create docs/section/ WITHOUT index.md
4381        let docs_dir = root.join("docs");
4382        std::fs::create_dir_all(&docs_dir).unwrap();
4383        let section_dir = docs_dir.join("section");
4384        std::fs::create_dir_all(&section_dir).unwrap();
4385        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4386
4387        // Create the source markdown file inside docs/
4388        let source_file = docs_dir.join("index.md");
4389        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4390
4391        let config = MD057Config {
4392            absolute_links: AbsoluteLinksOption::RelativeToDocs,
4393            ..Default::default()
4394        };
4395        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4396
4397        let content = "[sec](/section)\n";
4398        let ctx = crate::lint_context::LintContext::new(
4399            content,
4400            crate::config::MarkdownFlavor::Standard,
4401            Some(source_file.clone()),
4402        );
4403        let result = rule.check(&ctx).unwrap();
4404
4405        // MkDocs enforces index.md for directory links, so this should be flagged.
4406        assert_eq!(
4407            result.len(),
4408            1,
4409            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4410        );
4411        assert!(
4412            result[0].message.contains("index.md") || result[0].message.contains("section"),
4413            "Message should mention the directory or missing index.md: {}",
4414            result[0].message
4415        );
4416    }
4417
4418    /// Regression test for the edge case where a trailing-slash directory URL has a
4419    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
4420    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
4421    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
4422    /// The fix must still treat such links as directory links and require index.md.
4423    #[test]
4424    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
4425        let temp_dir = tempdir().unwrap();
4426        let root = temp_dir.path();
4427
4428        // Create directory `guide` WITHOUT index.md
4429        let guide_dir = root.join("guide");
4430        std::fs::create_dir_all(&guide_dir).unwrap();
4431        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4432
4433        // /guide/#intro has a trailing slash before the fragment — must require index.md
4434        let content = "[guide with fragment](/guide/#intro)\n";
4435
4436        let config = MD057Config {
4437            absolute_links: AbsoluteLinksOption::RelativeToRoots,
4438            roots: vec![],
4439            ..Default::default()
4440        };
4441        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4442        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4443        let result = rule.check(&ctx).unwrap();
4444
4445        assert_eq!(
4446            result.len(),
4447            1,
4448            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
4449        );
4450    }
4451}
4452
4453#[cfg(test)]
4454mod self_referential_links_tests {
4455    use super::*;
4456    use tempfile::tempdir;
4457
4458    /// A document written to `dir/<name>`, checked as itself.
4459    fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4460        let source_file = dir.join(name);
4461        std::fs::write(&source_file, content).unwrap();
4462        let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4463        let ctx =
4464            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4465        rule.check(&ctx).unwrap()
4466    }
4467
4468    fn enabled() -> MD057Config {
4469        MD057Config {
4470            self_referential_links: true,
4471            ..Default::default()
4472        }
4473    }
4474
4475    #[test]
4476    fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4477        let temp_dir = tempdir().unwrap();
4478        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4479        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4480
4481        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4482        assert_eq!(
4483            result[0].message,
4484            "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4485        );
4486        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4487        assert_eq!(fix.replacement, "#level-2-heading");
4488        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4489    }
4490
4491    #[test]
4492    fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4493        let temp_dir = tempdir().unwrap();
4494        let content = "# Title\n\nSee [this file](test.md).\n";
4495        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4496
4497        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4498        assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4499        assert!(
4500            result[0].fix.is_none(),
4501            "Dropping the link would change the document, so there is no fix"
4502        );
4503    }
4504
4505    #[test]
4506    fn test_the_check_is_off_by_default() {
4507        let temp_dir = tempdir().unwrap();
4508        let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4509        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4510
4511        assert!(result.is_empty(), "Off by default. Got: {result:?}");
4512    }
4513
4514    #[test]
4515    fn test_a_link_to_another_file_is_left_alone() {
4516        let temp_dir = tempdir().unwrap();
4517        std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4518        let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4519        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4520
4521        assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4522    }
4523
4524    #[test]
4525    fn test_a_self_link_written_with_traversal_reports_once() {
4526        let temp_dir = tempdir().unwrap();
4527        let sub_dir = temp_dir.path().join("sub");
4528        std::fs::create_dir_all(&sub_dir).unwrap();
4529
4530        let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4531        let config = MD057Config {
4532            self_referential_links: true,
4533            compact_paths: true,
4534            ..Default::default()
4535        };
4536        let result = check_as_file(&sub_dir, "test.md", content, config);
4537
4538        assert_eq!(
4539            result.len(),
4540            1,
4541            "A compacted path would still be a link back to this file. Got: {result:?}"
4542        );
4543        assert_eq!(
4544            result[0].message,
4545            "Relative link '../sub/test.md' points to the file it is in"
4546        );
4547    }
4548
4549    #[test]
4550    fn test_compact_paths_still_reports_a_link_to_another_file() {
4551        let temp_dir = tempdir().unwrap();
4552        let sub_dir = temp_dir.path().join("sub");
4553        std::fs::create_dir_all(&sub_dir).unwrap();
4554        std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4555
4556        let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4557        let config = MD057Config {
4558            self_referential_links: true,
4559            compact_paths: true,
4560            ..Default::default()
4561        };
4562        let result = check_as_file(&sub_dir, "test.md", content, config);
4563
4564        assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4565        assert_eq!(
4566            result[0].message,
4567            "Relative link '../sub/other.md' can be simplified to 'other.md'"
4568        );
4569    }
4570
4571    #[test]
4572    fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4573        let temp_dir = tempdir().unwrap();
4574        let content = "# Title\n\nSee [this file](test#title).\n";
4575        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4576
4577        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4578        assert_eq!(
4579            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4580            Some("#title"),
4581            "Got: {result:?}"
4582        );
4583    }
4584
4585    #[test]
4586    fn test_a_reference_definition_pointing_at_its_own_file() {
4587        let temp_dir = tempdir().unwrap();
4588        let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4589        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4590
4591        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4592        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4593        assert_eq!(fix.replacement, "#level-2-heading");
4594        assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4595    }
4596
4597    #[test]
4598    fn test_a_reference_definition_whose_label_repeats_the_destination() {
4599        let temp_dir = tempdir().unwrap();
4600        let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4601        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4602
4603        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4604        let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4605        // The label reads the same as the destination, so an unanchored search
4606        // would rewrite the label and orphan the usage above.
4607        assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4608        let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4609            .fix(&crate::lint_context::LintContext::new(
4610                content,
4611                crate::config::MarkdownFlavor::Standard,
4612                Some(temp_dir.path().join("test.md")),
4613            ))
4614            .unwrap();
4615        assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4616        assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4617    }
4618
4619    #[test]
4620    fn test_a_self_link_resolved_through_a_search_path() {
4621        let temp_dir = tempdir().unwrap();
4622        let guide_dir = temp_dir.path().join("docs/guide");
4623        std::fs::create_dir_all(&guide_dir).unwrap();
4624        let config = MD057Config {
4625            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4626            ..enabled()
4627        };
4628        let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4629        let result = check_as_file(&guide_dir, "test.md", content, config);
4630
4631        assert_eq!(
4632            result.len(),
4633            1,
4634            "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4635        );
4636        assert_eq!(
4637            result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4638            Some("#title"),
4639            "Got: {result:?}"
4640        );
4641    }
4642
4643    #[test]
4644    fn test_a_target_next_to_the_document_outranks_a_search_path() {
4645        let temp_dir = tempdir().unwrap();
4646        let guide_dir = temp_dir.path().join("docs/guide");
4647        std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4648        std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4649        let config = MD057Config {
4650            search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4651            ..enabled()
4652        };
4653        let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4654        let result = check_as_file(&guide_dir, "test.md", content, config);
4655
4656        assert!(
4657            result.is_empty(),
4658            "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4659        );
4660    }
4661
4662    #[test]
4663    fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4664        let temp_dir = tempdir().unwrap();
4665        let content = "# Title\n\n![not a navigation link](test.md)\n";
4666        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4667
4668        assert!(
4669            result.is_empty(),
4670            "An image is not a link the reader follows. Got: {result:?}"
4671        );
4672    }
4673
4674    #[test]
4675    fn test_a_query_string_is_reported_without_a_suggestion() {
4676        let temp_dir = tempdir().unwrap();
4677        let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4678        let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4679
4680        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4681        assert!(
4682            result[0].fix.is_none(),
4683            "A query does not survive losing its path. Got: {result:?}"
4684        );
4685    }
4686
4687    #[test]
4688    fn test_fix_rewrites_the_document_and_settles() {
4689        let temp_dir = tempdir().unwrap();
4690        let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4691        let source_file = temp_dir.path().join("test.md");
4692        std::fs::write(&source_file, content).unwrap();
4693
4694        let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4695        let ctx = crate::lint_context::LintContext::new(
4696            content,
4697            crate::config::MarkdownFlavor::Standard,
4698            Some(source_file.clone()),
4699        );
4700        let fixed = rule.fix(&ctx).unwrap();
4701        assert_eq!(
4702            fixed,
4703            "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4704        );
4705
4706        let refixed =
4707            crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4708        assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4709    }
4710
4711    #[test]
4712    fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4713        let unfixable = MD057ExistingRelativeLinks::default();
4714        assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4715
4716        let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4717        assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4718    }
4719
4720    #[test]
4721    fn test_the_option_is_read_from_kebab_and_snake_case() {
4722        let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4723        assert!(kebab.self_referential_links);
4724
4725        let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4726        assert!(snake.self_referential_links);
4727    }
4728
4729    fn front_matter_checked() -> MD057Config {
4730        MD057Config {
4731            check_frontmatter: true,
4732            ..Default::default()
4733        }
4734    }
4735
4736    #[test]
4737    fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4738        let temp_dir = tempdir().unwrap();
4739        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4740        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4741
4742        assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4743        assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4744        assert_eq!(result[0].line, 2);
4745        assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
4746        assert_eq!(result[0].end_column, 23);
4747    }
4748
4749    #[test]
4750    fn test_frontmatter_paths_are_not_checked_by_default() {
4751        let temp_dir = tempdir().unwrap();
4752        let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4753        let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4754
4755        assert!(
4756            result.is_empty(),
4757            "Frontmatter is only checked on request. Got: {result:?}"
4758        );
4759    }
4760
4761    #[test]
4762    fn test_an_existing_frontmatter_path_is_not_reported() {
4763        let temp_dir = tempdir().unwrap();
4764        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4765        let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
4766        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4767
4768        assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
4769        assert_eq!(result[0].line, 3);
4770    }
4771
4772    #[test]
4773    fn test_an_ignored_frontmatter_field_is_not_checked() {
4774        let temp_dir = tempdir().unwrap();
4775        let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
4776        let config = MD057Config {
4777            check_frontmatter: true,
4778            ignore_frontmatter_fields: vec!["Image".to_string()],
4779            ..Default::default()
4780        };
4781        let result = check_as_file(temp_dir.path(), "test.md", content, config);
4782
4783        assert_eq!(
4784            result.len(),
4785            1,
4786            "The ignored field is skipped and the other is not. Got: {result:?}"
4787        );
4788        assert_eq!(result[0].line, 3);
4789    }
4790
4791    #[test]
4792    fn test_an_external_frontmatter_url_is_not_reported() {
4793        let temp_dir = tempdir().unwrap();
4794        let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
4795        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4796
4797        assert!(
4798            result.is_empty(),
4799            "An external URL has no local target. Got: {result:?}"
4800        );
4801    }
4802
4803    #[test]
4804    fn test_a_frontmatter_fragment_is_left_to_md051() {
4805        let temp_dir = tempdir().unwrap();
4806        let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
4807        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4808
4809        assert!(
4810            result.is_empty(),
4811            "A fragment names a heading, not a file. Got: {result:?}"
4812        );
4813    }
4814
4815    #[test]
4816    fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
4817        let temp_dir = tempdir().unwrap();
4818        let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
4819
4820        let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
4821        assert!(
4822            ignored.is_empty(),
4823            "Absolute paths are ignored by default. Got: {ignored:?}"
4824        );
4825
4826        let warning_config = MD057Config {
4827            check_frontmatter: true,
4828            absolute_links: AbsoluteLinksOption::Warn,
4829            ..Default::default()
4830        };
4831        let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
4832        assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
4833        assert_eq!(
4834            warned[0].message,
4835            "Absolute link '/docs/guide.md' cannot be validated locally"
4836        );
4837    }
4838
4839    #[test]
4840    fn test_a_frontmatter_path_carrying_a_query_is_checked() {
4841        let temp_dir = tempdir().unwrap();
4842        std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
4843        let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
4844        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4845
4846        assert_eq!(
4847            result.len(),
4848            1,
4849            "A query names no file, so only the missing target is reported. Got: {result:?}"
4850        );
4851        assert_eq!(result[0].line, 2);
4852        assert_eq!(
4853            result[0].message,
4854            "Relative link 'docs/missing.md?raw=true' does not exist"
4855        );
4856    }
4857
4858    #[test]
4859    fn test_prose_in_frontmatter_is_not_read_as_a_path() {
4860        let temp_dir = tempdir().unwrap();
4861        let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
4862        let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4863
4864        assert!(
4865            result.is_empty(),
4866            "Only path-shaped values are destinations. Got: {result:?}"
4867        );
4868    }
4869}