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::range_utils::byte_to_char_count;
10use crate::workspace_index::{FileIndex, extract_cross_file_links};
11use regex::Regex;
12use std::collections::HashMap;
13use std::env;
14use std::path::{Path, PathBuf};
15use std::sync::LazyLock;
16use std::sync::{Arc, Mutex};
17
18mod md057_config;
19use crate::rule_config_serde::RuleConfig;
20use crate::utils::mkdocs_config::resolve_docs_dir;
21use crate::utils::obsidian_config::resolve_attachment_folder;
22use crate::utils::project_root::discover_project_root_from;
23pub use md057_config::{AbsoluteLinksOption, MD057Config};
24
25// Thread-safe cache for file existence checks to avoid redundant filesystem operations
26static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
27    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
28
29// Reset the file existence cache (typically between rule runs)
30fn reset_file_existence_cache() {
31    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
32        cache.clear();
33    }
34}
35
36// Check if a file exists with caching
37fn file_exists_with_cache(path: &Path) -> bool {
38    match FILE_EXISTENCE_CACHE.lock() {
39        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
40        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
41    }
42}
43
44/// Check if a file exists, also trying markdown extensions for extensionless links.
45/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
46fn file_exists_or_markdown_extension(path: &Path) -> bool {
47    // First, check exact path
48    if file_exists_with_cache(path) {
49        return true;
50    }
51
52    // If the path has no extension, try adding markdown extensions
53    if path.extension().is_none() {
54        for ext in MARKDOWN_EXTENSIONS {
55            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
56            let path_with_ext = path.with_extension(&ext[1..]);
57            if file_exists_with_cache(&path_with_ext) {
58                return true;
59            }
60        }
61    }
62
63    false
64}
65
66// Regex to match the start of a link - simplified for performance
67static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
68
69/// Regex to extract the URL from an angle-bracketed markdown link
70/// Format: `](<URL>)` or `](<URL> "title")`
71/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
72static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
73    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
74
75/// Regex to extract the URL from a normal markdown link (without angle brackets)
76/// Format: `](URL)` or `](URL "title")`
77static URL_EXTRACT_REGEX: LazyLock<Regex> =
78    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
79
80/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
81/// Matches: scheme:// or scheme: (per RFC 3986)
82/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
83static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
84    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
85
86// Current working directory
87static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
88
89/// Project root discovered once at process start by walking up from CWD looking
90/// for `.git`, `.rumdl.toml`, `pyproject.toml`, or `.markdownlint.json`. Used as
91/// the anchor for resolving non-absolute paths in `roots` and `search-paths`,
92/// and as the implicit fallback root for absolute-link validation. Tests that
93/// pass a path via `with_path()` bypass this discovery.
94static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
95
96/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
97/// Returns None for non-hex characters.
98#[inline]
99fn hex_digit_to_value(byte: u8) -> Option<u8> {
100    match byte {
101        b'0'..=b'9' => Some(byte - b'0'),
102        b'a'..=b'f' => Some(byte - b'a' + 10),
103        b'A'..=b'F' => Some(byte - b'A' + 10),
104        _ => None,
105    }
106}
107
108/// Supported markdown file extensions
109const MARKDOWN_EXTENSIONS: &[&str] = &[
110    ".md",
111    ".markdown",
112    ".mdx",
113    ".mkd",
114    ".mkdn",
115    ".mdown",
116    ".mdwn",
117    ".qmd",
118    ".rmd",
119];
120
121/// Rule MD057: Existing relative links should point to valid files or directories.
122#[derive(Debug, Clone)]
123pub struct MD057ExistingRelativeLinks {
124    /// Base directory for resolving relative links. Behind an `Arc<Mutex<..>>`
125    /// so it is shared across clones (the rule is cloned per config group and
126    /// for inline-config overrides); the base is resolved from the file under
127    /// check and consulted while validating that file's links.
128    base_path: Arc<Mutex<Option<PathBuf>>>,
129    /// Configuration for the rule
130    config: MD057Config,
131    /// Markdown flavor (used for Obsidian attachment folder auto-detection)
132    flavor: crate::config::MarkdownFlavor,
133}
134
135impl Default for MD057ExistingRelativeLinks {
136    fn default() -> Self {
137        Self {
138            base_path: Arc::new(Mutex::new(None)),
139            config: MD057Config::default(),
140            flavor: crate::config::MarkdownFlavor::default(),
141        }
142    }
143}
144
145impl MD057ExistingRelativeLinks {
146    /// Create a new instance with default settings
147    pub fn new() -> Self {
148        Self::default()
149    }
150
151    /// Set the base path for resolving relative links
152    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
153        let path = path.as_ref();
154        let dir_path = if path.is_file() {
155            path.parent().map(std::path::Path::to_path_buf)
156        } else {
157            Some(path.to_path_buf())
158        };
159
160        if let Ok(mut guard) = self.base_path.lock() {
161            *guard = dir_path;
162        }
163        self
164    }
165
166    pub fn from_config_struct(config: MD057Config) -> Self {
167        Self {
168            base_path: Arc::new(Mutex::new(None)),
169            config,
170            flavor: crate::config::MarkdownFlavor::default(),
171        }
172    }
173
174    /// Resolve a config-supplied path string (from `roots` or `search-paths`)
175    /// against the project root: absolute strings are taken verbatim, relative
176    /// strings are joined onto `project_root`.
177    fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
178        if Path::new(path_str).is_absolute() {
179            PathBuf::from(path_str)
180        } else {
181            project_root.join(path_str)
182        }
183    }
184
185    /// Set the markdown flavor for Obsidian attachment auto-detection
186    #[cfg(test)]
187    fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
188        self.flavor = flavor;
189        self
190    }
191
192    /// Check if a URL is external or should be skipped for validation.
193    ///
194    /// Returns `true` (skip validation) for:
195    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
196    /// - Bare domains: `www.example.com`, `example.com`
197    /// - Email addresses: `user@example.com` (without `mailto:`)
198    /// - Template variables: `{{URL}}`, `{{% include %}}`
199    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
200    ///
201    /// Returns `false` (validate) for:
202    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
203    #[inline]
204    fn is_external_url(&self, url: &str) -> bool {
205        if url.is_empty() {
206            return false;
207        }
208
209        // Quick checks for common external URL patterns
210        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
211            return true;
212        }
213
214        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
215        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
216        if url.starts_with("{{") || url.starts_with("{%") {
217            return true;
218        }
219
220        // Simple check: if URL contains @, it's almost certainly an email address
221        // File paths with @ are extremely rare, so this is a safe heuristic
222        if url.contains('@') {
223            return true; // It's an email address, skip it
224        }
225
226        // Bare domain check (e.g., "example.com")
227        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
228        // Links like [text](nodejs.org/path) without a protocol are broken -
229        // they'll be treated as relative paths by markdown renderers.
230        // Flagging them helps users find missing protocols.
231        // We only skip .com as a minimal safety net for the most common case.
232        // Require the absence of a path separator so a relative file reference
233        // that merely ends in ".com" (e.g. "../../vendor.com") is still
234        // validated rather than assumed to be a bare domain.
235        if !url.contains('/') && url.ends_with(".com") {
236            return true;
237        }
238
239        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
240        // These are not filesystem paths but module/asset aliases
241        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
242        if url.starts_with('~') || url.starts_with('@') {
243            return true;
244        }
245
246        // All other cases (relative paths, etc.) are not external
247        false
248    }
249
250    /// Check if the URL is a fragment-only link (internal document link)
251    #[inline]
252    fn is_fragment_only_link(&self, url: &str) -> bool {
253        url.starts_with('#')
254    }
255
256    /// Check if the URL is an absolute path (starts with /)
257    /// These are typically routes for published documentation sites.
258    #[inline]
259    fn is_absolute_path(url: &str) -> bool {
260        url.starts_with('/')
261    }
262
263    /// Decode URL percent-encoded sequences in a path.
264    /// Converts `%20` to space, `%2F` to `/`, etc.
265    /// Returns the original string if decoding fails or produces invalid UTF-8.
266    fn url_decode(path: &str) -> String {
267        // Quick check: if no percent sign, return as-is
268        if !path.contains('%') {
269            return path.to_string();
270        }
271
272        let bytes = path.as_bytes();
273        let mut result = Vec::with_capacity(bytes.len());
274        let mut i = 0;
275
276        while i < bytes.len() {
277            if bytes[i] == b'%' && i + 2 < bytes.len() {
278                // Try to parse the two hex digits following %
279                let hex1 = bytes[i + 1];
280                let hex2 = bytes[i + 2];
281                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
282                    result.push(d1 * 16 + d2);
283                    i += 3;
284                    continue;
285                }
286            }
287            result.push(bytes[i]);
288            i += 1;
289        }
290
291        // Convert to UTF-8, falling back to original if invalid
292        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
293    }
294
295    /// Strip query parameters and fragments from a URL for file existence checking.
296    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
297    /// for `path/to/image.png` or `file.md` respectively.
298    ///
299    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
300    /// so we check for `?` first. If a URL has both, only the query is stripped here
301    /// (fragments are handled separately by the regex in `contribute_to_index`).
302    fn strip_query_and_fragment(url: &str) -> &str {
303        // Find the first occurrence of '?' or '#', whichever comes first
304        // This handles both standard URLs (? before #) and edge cases (# before ?)
305        let query_pos = url.find('?');
306        let fragment_pos = url.find('#');
307
308        match (query_pos, fragment_pos) {
309            (Some(q), Some(f)) => {
310                // Both exist - strip at whichever comes first
311                &url[..q.min(f)]
312            }
313            (Some(q), None) => &url[..q],
314            (None, Some(f)) => &url[..f],
315            (None, None) => url,
316        }
317    }
318
319    /// Resolve a relative link against a provided base path
320    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
321        base_path.join(link)
322    }
323
324    /// Compute additional search paths for fallback link resolution.
325    ///
326    /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian)
327    /// with explicitly configured `search-paths`.
328    fn compute_search_paths(
329        &self,
330        flavor: crate::config::MarkdownFlavor,
331        source_file: Option<&Path>,
332        base_path: &Path,
333        project_root: &Path,
334    ) -> Vec<PathBuf> {
335        let mut paths = Vec::new();
336
337        // Auto-detect Obsidian attachment folder
338        if flavor == crate::config::MarkdownFlavor::Obsidian
339            && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
340            && attachment_dir != *base_path
341        {
342            paths.push(attachment_dir);
343        }
344
345        // Add explicitly configured search paths. Resolved relative to the
346        // discovered project root so paths are stable regardless of which
347        // subdirectory rumdl is invoked from.
348        for search_path in &self.config.search_paths {
349            let resolved = Self::resolve_against_project_root(search_path, project_root);
350            if resolved != *base_path && !paths.contains(&resolved) {
351                paths.push(resolved);
352            }
353        }
354
355        paths
356    }
357
358    /// Check if a link target exists in any of the additional search paths.
359    fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
360        search_paths.iter().any(|dir| {
361            let candidate = dir.join(decoded_path);
362            file_exists_or_markdown_extension(&candidate)
363        })
364    }
365
366    /// Check if a relative link can be compacted and return the simplified form.
367    ///
368    /// Returns `None` if compact-paths is disabled, the link has no traversal,
369    /// or the link is already the shortest form.
370    /// Returns `Some(suggestion)` with the full compacted URL (including fragment/query suffix).
371    fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
372        if !self.config.compact_paths {
373            return None;
374        }
375
376        // Split URL into path and suffix (fragment/query)
377        let path_end = url
378            .find('?')
379            .unwrap_or(url.len())
380            .min(url.find('#').unwrap_or(url.len()));
381        let path_part = &url[..path_end];
382        let suffix = &url[path_end..];
383
384        // URL-decode the path portion for filesystem resolution
385        let decoded_path = Self::url_decode(path_part);
386
387        compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
388    }
389
390    /// Validate an absolute link by resolving it relative to MkDocs docs_dir.
391    ///
392    /// Returns `Some(warning_message)` if the link is broken, `None` if valid.
393    /// Falls back to a generic warning if no mkdocs.yml is found.
394    /// Validate an absolute link against the MkDocs `docs_dir`.
395    fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
396        let Some(docs_dir) = resolve_docs_dir(source_path) else {
397            return Some(format!(
398                "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
399            ));
400        };
401
402        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
403
404        // MkDocs mode: an extensionless directory link must have index.md.
405        // `require_index_for_dirs = true` enforces this for all directory hits.
406        match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
407            Resolution::Found => None,
408            Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
409                "Absolute link '{url}' resolves to directory '{}' which has no index.md",
410                resolved.display()
411            )),
412            Resolution::NotFound { resolved } => Some(format!(
413                "Absolute link '{url}' resolves to '{}' which does not exist",
414                resolved.display()
415            )),
416        }
417    }
418
419    /// Validate an absolute link by resolving it against each configured root and the project root.
420    ///
421    /// Configured `roots` are tried first (first match wins), then the project
422    /// root is tried as an implicit fallback. The fallback supports links
423    /// written as literal absolute paths from the project root (e.g.
424    /// `/content/en/foo.md`) alongside links written relative to a configured
425    /// root (e.g. `/foo.md` with `roots = ["content/en"]`). A warning is
426    /// emitted only when no root — configured or implicit — contains the target.
427    fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
428        let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
429
430        for root in roots {
431            let root_path = Self::resolve_against_project_root(root, project_root);
432            // Filesystem mode: an existing directory without trailing slash is valid.
433            // `require_index_for_dirs = false` aligns with relative-link behavior. (#632)
434            if matches!(
435                Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
436                Resolution::Found
437            ) {
438                return None;
439            }
440        }
441
442        if matches!(
443            // Filesystem mode: see above.
444            Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
445            Resolution::Found
446        ) {
447            return None;
448        }
449
450        let msg = if roots.is_empty() {
451            format!("Absolute link '{url}' was not found under the project root")
452        } else {
453            format!("Absolute link '{url}' was not found under any configured root or the project root")
454        };
455        Some(msg)
456    }
457
458    /// Decode an absolute-link URL into a filesystem-relative path and a
459    /// directory-link flag. Strips the leading `/`, query/fragment suffix, and
460    /// percent-encoding.
461    fn prepare_absolute_url(url: &str) -> (String, bool) {
462        let relative_url = url.trim_start_matches('/');
463        let file_path = Self::strip_query_and_fragment(relative_url);
464        let decoded = Self::url_decode(file_path);
465        let is_directory_link = url.ends_with('/') || decoded.is_empty();
466        (decoded, is_directory_link)
467    }
468
469    /// Try to resolve a decoded absolute-link path under a single root directory.
470    ///
471    /// `require_index_for_dirs` controls how extensionless links that resolve to a
472    /// directory are treated:
473    ///
474    /// - `true` (MkDocs / docs-dir mode): a directory must contain `index.md` to be
475    ///   considered valid, even when the link has no trailing slash. This matches
476    ///   MkDocs' URL routing convention where `/section` serves `section/index.md`.
477    ///
478    /// - `false` (roots / filesystem mode): an existing directory is accepted as a
479    ///   valid target for an extensionless link, matching the behavior of relative
480    ///   links (which use `path.exists()`). Only an explicit trailing-slash link
481    ///   (`is_directory_link == true`) still requires `index.md`.
482    ///
483    /// Applies resolution strategies in order:
484    /// 1. Directory-style links (explicit `/` suffix or `require_index_for_dirs`):
485    ///    look for `<resolved>/index.md`; report `DirectoryWithoutIndex` on failure.
486    /// 2. Filesystem-mode directory hit (`require_index_for_dirs == false` and
487    ///    `is_directory_link == false`): accept the existing directory as `Found`.
488    /// 3. Direct existence (with markdown-extension fallback for extensionless links).
489    /// 4. `.html`/`.htm` links: look for a markdown source with the same stem.
490    fn resolve_under_root_with_opts(
491        root_path: &Path,
492        decoded: &str,
493        is_directory_link: bool,
494        require_index_for_dirs: bool,
495    ) -> Resolution {
496        let resolved = root_path.join(decoded);
497
498        let is_dir = resolved.is_dir();
499
500        // When the link explicitly ends with `/` or the caller requires index.md
501        // for all directory hits (MkDocs mode), apply the stricter check first.
502        // Must be checked before `file_exists_or_markdown_extension` because
503        // `path.exists()` returns `true` for directories.
504        if is_directory_link || (require_index_for_dirs && is_dir) {
505            let index_path = resolved.join("index.md");
506            if file_exists_with_cache(&index_path) {
507                return Resolution::Found;
508            }
509            if is_dir {
510                return Resolution::DirectoryWithoutIndex { resolved };
511            }
512        }
513
514        // Filesystem mode (roots): an existing directory without a trailing slash
515        // is valid — mirrors how relative links accept directories via `path.exists()`.
516        // Exclude decoded paths that end with `/`: a URL like `/guide/#intro` strips
517        // the fragment to `guide/`, so `decoded` carries the trailing slash even though
518        // `is_directory_link` is false (the raw URL ends with `#intro`, not `/`).
519        let decoded_has_trailing_slash = decoded.ends_with('/');
520        if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
521            return Resolution::Found;
522        }
523
524        if file_exists_or_markdown_extension(&resolved) {
525            return Resolution::Found;
526        }
527
528        // For .html/.htm links, accept a matching markdown source in the same
529        // directory — supports doc sites that compile .md to .html.
530        if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
531            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
532            && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
533        {
534            let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
535                let source_path = parent.join(format!("{stem}{md_ext}"));
536                file_exists_with_cache(&source_path)
537            });
538            if has_md_source {
539                return Resolution::Found;
540            }
541        }
542
543        Resolution::NotFound { resolved }
544    }
545}
546
547/// Outcome of trying to resolve an absolute link under a single root directory.
548/// Carries the resolved path on the failure variants so callers can build
549/// specific error messages without recomputing it.
550enum Resolution {
551    Found,
552    DirectoryWithoutIndex { resolved: PathBuf },
553    NotFound { resolved: PathBuf },
554}
555
556impl Rule for MD057ExistingRelativeLinks {
557    fn name(&self) -> &'static str {
558        "MD057"
559    }
560
561    fn description(&self) -> &'static str {
562        "Relative links should point to existing files"
563    }
564
565    fn category(&self) -> RuleCategory {
566        RuleCategory::Link
567    }
568
569    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
570        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
571    }
572
573    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
574        let content = ctx.content;
575
576        // Early returns for performance
577        if content.is_empty() || !content.contains('[') {
578            return Ok(Vec::new());
579        }
580
581        // Quick check for any potential links before expensive operations
582        // Check for inline links "](", reference definitions "]:", or images "!["
583        if !content.contains("](") && !content.contains("]:") {
584            return Ok(Vec::new());
585        }
586
587        // Reset the file existence cache for a fresh run
588        reset_file_existence_cache();
589
590        let mut warnings = Vec::new();
591
592        // Read the explicit base path (set via `with_path()` in tests) once; it
593        // doubles as both the per-file base path and the project root override
594        // for absolute-link resolution.
595        let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
596
597        // Project root used for absolute-link resolution against configured
598        // `roots` and as the implicit fallback root. The explicit base wins
599        // when set; otherwise the discovered project root is used.
600        let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
601
602        // Determine base path for resolving relative links.
603        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
604        // This ensures each file resolves links relative to its own directory.
605        let base_path: Option<PathBuf> = {
606            if explicit_base.is_some() {
607                explicit_base
608            } else if let Some(ref source_file) = ctx.source_file {
609                // Resolve symlinks to get the actual file location
610                // This ensures relative links are resolved from the target's directory,
611                // not the symlink's directory
612                let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
613                resolved_file
614                    .parent()
615                    .map(std::path::Path::to_path_buf)
616                    .or_else(|| Some(CURRENT_DIR.clone()))
617            } else {
618                // No source file available - cannot validate relative links
619                None
620            }
621        };
622
623        // If we still don't have a base path, we can't validate relative links
624        let Some(base_path) = base_path else {
625            return Ok(warnings);
626        };
627
628        // Compute additional search paths for fallback link resolution
629        let extra_search_paths =
630            self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
631
632        // Use LintContext links instead of expensive regex parsing
633        if !ctx.links.is_empty() {
634            // Use LineIndex for correct position calculation across all line ending types
635            let line_index = &ctx.line_index;
636
637            // Pre-collected lines from context
638            let lines = ctx.raw_lines();
639
640            // Track which lines we've already processed to avoid duplicates
641            // (ctx.links may have multiple entries for the same line, especially with malformed markdown)
642            let mut processed_lines = std::collections::HashSet::new();
643
644            for link in &ctx.links {
645                let line_idx = link.line - 1;
646                if line_idx >= lines.len() {
647                    continue;
648                }
649
650                // Skip lines inside PyMdown blocks
651                if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
652                    continue;
653                }
654
655                // Skip if we've already processed this line
656                if !processed_lines.insert(line_idx) {
657                    continue;
658                }
659
660                let line = lines[line_idx];
661
662                // Quick check for link pattern in this line
663                if !line.contains("](") {
664                    continue;
665                }
666
667                // Find all links in this line using optimized regex
668                for link_match in LINK_START_REGEX.find_iter(line) {
669                    let start_pos = link_match.start();
670                    let end_pos = link_match.end();
671
672                    // Calculate absolute position using LineIndex
673                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
674                    let absolute_start_pos = line_start_byte + start_pos;
675
676                    // Skip if this link is in a code span
677                    if ctx.is_in_code_span_byte(absolute_start_pos) {
678                        continue;
679                    }
680
681                    // Skip if this link is in a math span (LaTeX $...$ or $$...$$)
682                    if ctx.is_in_math_span(absolute_start_pos) {
683                        continue;
684                    }
685
686                    // Find the URL part after the link text
687                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
688                    // Then fall back to normal URL regex
689                    let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
690                        .captures_at(line, end_pos - 1)
691                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
692                        .or_else(|| {
693                            URL_EXTRACT_REGEX
694                                .captures_at(line, end_pos - 1)
695                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
696                        });
697
698                    if let Some((caps, url_group)) = caps_and_url {
699                        let url = url_group.as_str().trim();
700
701                        // Skip empty URLs
702                        if url.is_empty() {
703                            continue;
704                        }
705
706                        // Skip rustdoc intra-doc links (backtick-wrapped URLs)
707                        // These are Rust API references, not file paths
708                        // Example: [`f32::is_subnormal`], [`Vec::push`]
709                        if url.starts_with('`') && url.ends_with('`') {
710                            continue;
711                        }
712
713                        // Skip external URLs and fragment-only links
714                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
715                            continue;
716                        }
717
718                        // Handle absolute paths based on config
719                        if Self::is_absolute_path(url) {
720                            match self.config.absolute_links {
721                                AbsoluteLinksOption::Warn => {
722                                    let url_start = url_group.start();
723                                    let url_end = url_group.end();
724                                    warnings.push(LintWarning {
725                                        rule_name: Some(self.name().to_string()),
726                                        line: link.line,
727                                        column: byte_to_char_count(line, url_start),
728                                        end_line: link.line,
729                                        end_column: byte_to_char_count(line, url_end),
730                                        message: format!("Absolute link '{url}' cannot be validated locally"),
731                                        severity: Severity::Warning,
732                                        fix: None,
733                                    });
734                                }
735                                AbsoluteLinksOption::RelativeToDocs => {
736                                    if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
737                                        let url_start = url_group.start();
738                                        let url_end = url_group.end();
739                                        warnings.push(LintWarning {
740                                            rule_name: Some(self.name().to_string()),
741                                            line: link.line,
742                                            column: byte_to_char_count(line, url_start),
743                                            end_line: link.line,
744                                            end_column: byte_to_char_count(line, url_end),
745                                            message: msg,
746                                            severity: Severity::Warning,
747                                            fix: None,
748                                        });
749                                    }
750                                }
751                                AbsoluteLinksOption::RelativeToRoots => {
752                                    if let Some(msg) =
753                                        Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
754                                    {
755                                        let url_start = url_group.start();
756                                        let url_end = url_group.end();
757                                        warnings.push(LintWarning {
758                                            rule_name: Some(self.name().to_string()),
759                                            line: link.line,
760                                            column: byte_to_char_count(line, url_start),
761                                            end_line: link.line,
762                                            end_column: byte_to_char_count(line, url_end),
763                                            message: msg,
764                                            severity: Severity::Warning,
765                                            fix: None,
766                                        });
767                                    }
768                                }
769                                AbsoluteLinksOption::Ignore => {}
770                            }
771                            continue;
772                        }
773
774                        // Check for unnecessary path traversal (compact-paths)
775                        // Reconstruct full URL including fragment (regex group 2)
776                        // since url_group (group 1) contains only the path part
777                        let full_url_for_compact = if let Some(frag) = caps.get(2) {
778                            format!("{url}{}", frag.as_str())
779                        } else {
780                            url.to_string()
781                        };
782                        if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
783                            let url_start = url_group.start();
784                            let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
785                            let fix_byte_start = line_start_byte + url_start;
786                            let fix_byte_end = line_start_byte + url_end;
787                            warnings.push(LintWarning {
788                                rule_name: Some(self.name().to_string()),
789                                line: link.line,
790                                column: byte_to_char_count(line, url_start),
791                                end_line: link.line,
792                                end_column: byte_to_char_count(line, url_end),
793                                message: format!(
794                                    "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
795                                ),
796                                severity: Severity::Warning,
797                                fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
798                            });
799                        }
800
801                        // Strip query parameters and fragments before checking file existence
802                        let file_path = Self::strip_query_and_fragment(url);
803
804                        // URL-decode the path to handle percent-encoded characters
805                        let decoded_path = Self::url_decode(file_path);
806
807                        // Resolve the relative link against the base path
808                        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
809
810                        // Check if the file exists, also trying markdown extensions for extensionless links
811                        if file_exists_or_markdown_extension(&resolved_path) {
812                            continue; // File exists, no warning needed
813                        }
814
815                        // For .html/.htm links, check if a corresponding markdown source exists
816                        let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
817                            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
818                            && let (Some(stem), Some(parent)) = (
819                                resolved_path.file_stem().and_then(|s| s.to_str()),
820                                resolved_path.parent(),
821                            ) {
822                            MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
823                                let source_path = parent.join(format!("{stem}{md_ext}"));
824                                file_exists_with_cache(&source_path)
825                            })
826                        } else {
827                            false
828                        };
829
830                        if has_md_source {
831                            continue; // Markdown source exists, link is valid
832                        }
833
834                        // Try additional search paths (Obsidian attachment folder, configured paths)
835                        if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
836                            continue;
837                        }
838
839                        // File doesn't exist and no source file found
840                        // Use actual URL position from regex capture group
841                        // Note: capture group positions are absolute within the line string
842                        let url_start = url_group.start();
843                        let url_end = url_group.end();
844
845                        warnings.push(LintWarning {
846                            rule_name: Some(self.name().to_string()),
847                            line: link.line,
848                            column: byte_to_char_count(line, url_start),
849                            end_line: link.line,
850                            end_column: byte_to_char_count(line, url_end),
851                            message: format!("Relative link '{url}' does not exist"),
852                            severity: Severity::Error,
853                            fix: None,
854                        });
855                    }
856                }
857            }
858        }
859
860        // Also process images - they have URLs already parsed
861        for image in &ctx.images {
862            // Skip images inside PyMdown blocks (MkDocs flavor)
863            if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
864                continue;
865            }
866
867            let url = image.url.as_ref();
868
869            // Skip empty URLs
870            if url.is_empty() {
871                continue;
872            }
873
874            // Skip external URLs and fragment-only links
875            if self.is_external_url(url) || self.is_fragment_only_link(url) {
876                continue;
877            }
878
879            // Handle absolute paths based on config
880            if Self::is_absolute_path(url) {
881                match self.config.absolute_links {
882                    AbsoluteLinksOption::Warn => {
883                        warnings.push(LintWarning {
884                            rule_name: Some(self.name().to_string()),
885                            line: image.line,
886                            column: image.start_col + 1,
887                            end_line: image.line,
888                            end_column: image.start_col + 1 + url.chars().count(),
889                            message: format!("Absolute link '{url}' cannot be validated locally"),
890                            severity: Severity::Warning,
891                            fix: None,
892                        });
893                    }
894                    AbsoluteLinksOption::RelativeToDocs => {
895                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
896                            warnings.push(LintWarning {
897                                rule_name: Some(self.name().to_string()),
898                                line: image.line,
899                                column: image.start_col + 1,
900                                end_line: image.line,
901                                end_column: image.start_col + 1 + url.chars().count(),
902                                message: msg,
903                                severity: Severity::Warning,
904                                fix: None,
905                            });
906                        }
907                    }
908                    AbsoluteLinksOption::RelativeToRoots => {
909                        if let Some(msg) =
910                            Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
911                        {
912                            warnings.push(LintWarning {
913                                rule_name: Some(self.name().to_string()),
914                                line: image.line,
915                                column: image.start_col + 1,
916                                end_line: image.line,
917                                end_column: image.start_col + 1 + url.chars().count(),
918                                message: msg,
919                                severity: Severity::Warning,
920                                fix: None,
921                            });
922                        }
923                    }
924                    AbsoluteLinksOption::Ignore => {}
925                }
926                continue;
927            }
928
929            // Check for unnecessary path traversal (compact-paths)
930            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
931                // Find the URL position within the image syntax using document byte offsets.
932                // Search from image.byte_offset (the `!` character) to locate the URL string.
933                let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
934                    let fix_byte_start = image.byte_offset + url_offset;
935                    let fix_byte_end = fix_byte_start + url.len();
936                    Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
937                });
938
939                let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
940                let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
941                // The fix range is a document byte offset; the displayed column is
942                // the corresponding character offset within the line.
943                let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
944                    byte_to_char_count(image_line, f.range.start - img_line_start_byte)
945                });
946                warnings.push(LintWarning {
947                    rule_name: Some(self.name().to_string()),
948                    line: image.line,
949                    column: url_col,
950                    end_line: image.line,
951                    end_column: url_col + url.chars().count(),
952                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
953                    severity: Severity::Warning,
954                    fix,
955                });
956            }
957
958            // Strip query parameters and fragments before checking file existence
959            let file_path = Self::strip_query_and_fragment(url);
960
961            // URL-decode the path to handle percent-encoded characters
962            let decoded_path = Self::url_decode(file_path);
963
964            // Resolve the relative link against the base path
965            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
966
967            // Check if the file exists, also trying markdown extensions for extensionless links
968            if file_exists_or_markdown_extension(&resolved_path) {
969                continue; // File exists, no warning needed
970            }
971
972            // For .html/.htm links, check if a corresponding markdown source exists
973            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
974                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
975                && let (Some(stem), Some(parent)) = (
976                    resolved_path.file_stem().and_then(|s| s.to_str()),
977                    resolved_path.parent(),
978                ) {
979                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
980                    let source_path = parent.join(format!("{stem}{md_ext}"));
981                    file_exists_with_cache(&source_path)
982                })
983            } else {
984                false
985            };
986
987            if has_md_source {
988                continue; // Markdown source exists, link is valid
989            }
990
991            // Try additional search paths (Obsidian attachment folder, configured paths)
992            if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
993                continue;
994            }
995
996            // File doesn't exist and no source file found
997            // Images already have correct position from parser
998            warnings.push(LintWarning {
999                rule_name: Some(self.name().to_string()),
1000                line: image.line,
1001                column: image.start_col + 1,
1002                end_line: image.line,
1003                end_column: image.start_col + 1 + url.chars().count(),
1004                message: format!("Relative link '{url}' does not exist"),
1005                severity: Severity::Error,
1006                fix: None,
1007            });
1008        }
1009
1010        // Also process reference definitions: [ref]: ./path.md
1011        for ref_def in &ctx.reference_defs {
1012            let url = &ref_def.url;
1013
1014            // Skip empty URLs
1015            if url.is_empty() {
1016                continue;
1017            }
1018
1019            // Skip external URLs and fragment-only links
1020            if self.is_external_url(url) || self.is_fragment_only_link(url) {
1021                continue;
1022            }
1023
1024            // Handle absolute paths based on config
1025            if Self::is_absolute_path(url) {
1026                match self.config.absolute_links {
1027                    AbsoluteLinksOption::Warn => {
1028                        let line_idx = ref_def.line - 1;
1029                        let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1030                            line_content
1031                                .find(url.as_str())
1032                                .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1033                        });
1034                        warnings.push(LintWarning {
1035                            rule_name: Some(self.name().to_string()),
1036                            line: ref_def.line,
1037                            column,
1038                            end_line: ref_def.line,
1039                            end_column: column + url.chars().count(),
1040                            message: format!("Absolute link '{url}' cannot be validated locally"),
1041                            severity: Severity::Warning,
1042                            fix: None,
1043                        });
1044                    }
1045                    AbsoluteLinksOption::RelativeToDocs => {
1046                        if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
1047                            let line_idx = ref_def.line - 1;
1048                            let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1049                                line_content
1050                                    .find(url.as_str())
1051                                    .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1052                            });
1053                            warnings.push(LintWarning {
1054                                rule_name: Some(self.name().to_string()),
1055                                line: ref_def.line,
1056                                column,
1057                                end_line: ref_def.line,
1058                                end_column: column + url.chars().count(),
1059                                message: msg,
1060                                severity: Severity::Warning,
1061                                fix: None,
1062                            });
1063                        }
1064                    }
1065                    AbsoluteLinksOption::RelativeToRoots => {
1066                        if let Some(msg) =
1067                            Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
1068                        {
1069                            let line_idx = ref_def.line - 1;
1070                            let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1071                                line_content
1072                                    .find(url.as_str())
1073                                    .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1074                            });
1075                            warnings.push(LintWarning {
1076                                rule_name: Some(self.name().to_string()),
1077                                line: ref_def.line,
1078                                column,
1079                                end_line: ref_def.line,
1080                                end_column: column + url.chars().count(),
1081                                message: msg,
1082                                severity: Severity::Warning,
1083                                fix: None,
1084                            });
1085                        }
1086                    }
1087                    AbsoluteLinksOption::Ignore => {}
1088                }
1089                continue;
1090            }
1091
1092            // Check for unnecessary path traversal (compact-paths)
1093            if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1094                let ref_line_idx = ref_def.line - 1;
1095                let line_content = ctx.raw_lines().get(ref_line_idx).copied().unwrap_or("");
1096                // Byte offset of the URL within the line drives the fix range;
1097                // the displayed column is the corresponding character offset.
1098                let url_byte = line_content.find(url.as_str());
1099                let col = url_byte.map_or(1, |b| byte_to_char_count(line_content, b));
1100                let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1101                let fix_byte_start = ref_line_start_byte + url_byte.unwrap_or(0);
1102                let fix_byte_end = fix_byte_start + url.len();
1103                warnings.push(LintWarning {
1104                    rule_name: Some(self.name().to_string()),
1105                    line: ref_def.line,
1106                    column: col,
1107                    end_line: ref_def.line,
1108                    end_column: col + url.chars().count(),
1109                    message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1110                    severity: Severity::Warning,
1111                    fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1112                });
1113            }
1114
1115            // Strip query parameters and fragments before checking file existence
1116            let file_path = Self::strip_query_and_fragment(url);
1117
1118            // URL-decode the path to handle percent-encoded characters
1119            let decoded_path = Self::url_decode(file_path);
1120
1121            // Resolve the relative link against the base path
1122            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1123
1124            // Check if the file exists, also trying markdown extensions for extensionless links
1125            if file_exists_or_markdown_extension(&resolved_path) {
1126                continue; // File exists, no warning needed
1127            }
1128
1129            // For .html/.htm links, check if a corresponding markdown source exists
1130            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1131                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1132                && let (Some(stem), Some(parent)) = (
1133                    resolved_path.file_stem().and_then(|s| s.to_str()),
1134                    resolved_path.parent(),
1135                ) {
1136                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1137                    let source_path = parent.join(format!("{stem}{md_ext}"));
1138                    file_exists_with_cache(&source_path)
1139                })
1140            } else {
1141                false
1142            };
1143
1144            if has_md_source {
1145                continue; // Markdown source exists, link is valid
1146            }
1147
1148            // Try additional search paths (Obsidian attachment folder, configured paths)
1149            if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1150                continue;
1151            }
1152
1153            // File doesn't exist and no source file found
1154            // Calculate column position: find URL within the line
1155            let line_idx = ref_def.line - 1;
1156            let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1157                // Find URL position in line (after ]: )
1158                line_content
1159                    .find(url.as_str())
1160                    .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1161            });
1162
1163            warnings.push(LintWarning {
1164                rule_name: Some(self.name().to_string()),
1165                line: ref_def.line,
1166                column,
1167                end_line: ref_def.line,
1168                end_column: column + url.chars().count(),
1169                message: format!("Relative link '{url}' does not exist"),
1170                severity: Severity::Error,
1171                fix: None,
1172            });
1173        }
1174
1175        Ok(warnings)
1176    }
1177
1178    fn fix_capability(&self) -> FixCapability {
1179        if self.config.compact_paths {
1180            FixCapability::ConditionallyFixable
1181        } else {
1182            FixCapability::Unfixable
1183        }
1184    }
1185
1186    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1187        if !self.config.compact_paths {
1188            return Ok(ctx.content.to_string());
1189        }
1190
1191        let warnings = self.check(ctx)?;
1192        let warnings =
1193            crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1194        let mut content = ctx.content.to_string();
1195
1196        // Collect fixable warnings (compact-paths) sorted by byte offset descending
1197        let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1198        fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1199
1200        for fix in fixes {
1201            if fix.range.end <= content.len() {
1202                content.replace_range(fix.range.clone(), &fix.replacement);
1203            }
1204        }
1205
1206        Ok(content)
1207    }
1208
1209    fn as_any(&self) -> &dyn std::any::Any {
1210        self
1211    }
1212
1213    fn default_config_section(&self) -> Option<(String, toml::Value)> {
1214        let default_config = MD057Config::default();
1215        let json_value = serde_json::to_value(&default_config).ok()?;
1216        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1217
1218        if let toml::Value::Table(table) = toml_value {
1219            if !table.is_empty() {
1220                Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1221            } else {
1222                None
1223            }
1224        } else {
1225            None
1226        }
1227    }
1228
1229    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1230    where
1231        Self: Sized,
1232    {
1233        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1234        let mut rule = Self::from_config_struct(rule_config);
1235        rule.flavor = config.global.flavor;
1236        Box::new(rule)
1237    }
1238
1239    fn cross_file_scope(&self) -> CrossFileScope {
1240        CrossFileScope::Workspace
1241    }
1242
1243    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1244        // Use the shared utility for cross-file link extraction
1245        // This ensures consistent position tracking between CLI and LSP
1246        let links = extract_cross_file_links(ctx);
1247        for link in links.relative {
1248            index.add_cross_file_link(link);
1249        }
1250        // Root-relative links are not linted, but indexing them keeps the cached
1251        // index complete so the LSP can resolve them for find-references.
1252        for link in links.root_relative {
1253            index.add_root_relative_link(link);
1254        }
1255    }
1256
1257    fn cross_file_check(
1258        &self,
1259        _file_path: &Path,
1260        _file_index: &FileIndex,
1261        _workspace_index: &crate::workspace_index::WorkspaceIndex,
1262    ) -> LintResult {
1263        // All link targets are already validated by check() on each per-file pass.
1264        // check() resolves relative links against the file's own directory, handles
1265        // configured search paths, and applies the absolute_links config.
1266        // Validating them here too would produce identical duplicate warnings for
1267        // every broken link. (#631)
1268        //
1269        // The cross_file_scope / contribute_to_index / workspace-index infrastructure
1270        // remains in place to support future cross-file analyses (e.g. heading-anchor
1271        // validation across files).
1272        Ok(Vec::new())
1273    }
1274}
1275
1276/// Compute the shortest relative path from `from_dir` to `to_path`.
1277///
1278/// Both paths must be normalized (no `.` or `..` components).
1279/// Returns a relative `PathBuf` that navigates from `from_dir` to `to_path`.
1280fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1281    let from_components: Vec<_> = from_dir.components().collect();
1282    let to_components: Vec<_> = to_path.components().collect();
1283
1284    // Find common prefix length
1285    let common_len = from_components
1286        .iter()
1287        .zip(to_components.iter())
1288        .take_while(|(a, b)| a == b)
1289        .count();
1290
1291    let mut result = PathBuf::new();
1292
1293    // Go up for each remaining component in from_dir
1294    for _ in common_len..from_components.len() {
1295        result.push("..");
1296    }
1297
1298    // Append remaining components from to_path
1299    for component in &to_components[common_len..] {
1300        result.push(component);
1301    }
1302
1303    result
1304}
1305
1306/// Check if a relative link path can be shortened.
1307///
1308/// Given the source directory and the raw link path, computes whether there's
1309/// a shorter equivalent path. Returns `Some(compact_path)` if the link can
1310/// be simplified, `None` if it's already optimal.
1311fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1312    let link_path = Path::new(raw_link_path);
1313
1314    // Only check paths that contain traversal (../ or ./)
1315    let has_traversal = link_path
1316        .components()
1317        .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1318
1319    if !has_traversal {
1320        return None;
1321    }
1322
1323    // Resolve: source_dir + raw_link_path, then normalize
1324    let combined = source_dir.join(link_path);
1325    let normalized_target = normalize_path(&combined);
1326
1327    // Compute shortest path from source_dir back to the normalized target
1328    let normalized_source = normalize_path(source_dir);
1329    let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1330
1331    // Compare against the raw link path — if it differs, the path can be compacted
1332    if shortest != link_path {
1333        let compact = shortest.to_string_lossy().to_string();
1334        // Avoid suggesting empty path
1335        if compact.is_empty() {
1336            return None;
1337        }
1338        // Markdown links always use forward slashes regardless of platform
1339        Some(compact.replace('\\', "/"))
1340    } else {
1341        None
1342    }
1343}
1344
1345/// Normalize a path by resolving . and .. components
1346fn normalize_path(path: &Path) -> PathBuf {
1347    let mut components = Vec::new();
1348
1349    for component in path.components() {
1350        match component {
1351            std::path::Component::ParentDir => {
1352                // Go up one level if possible
1353                if !components.is_empty() {
1354                    components.pop();
1355                }
1356            }
1357            std::path::Component::CurDir => {
1358                // Skip current directory markers
1359            }
1360            _ => {
1361                components.push(component);
1362            }
1363        }
1364    }
1365
1366    components.iter().collect()
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371    use super::*;
1372    use crate::workspace_index::CrossFileLinkIndex;
1373    use std::fs::File;
1374    use std::io::Write;
1375    use tempfile::tempdir;
1376
1377    #[test]
1378    fn test_strip_query_and_fragment() {
1379        // Test query parameter stripping
1380        assert_eq!(
1381            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1382            "file.png"
1383        );
1384        assert_eq!(
1385            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1386            "file.png"
1387        );
1388        assert_eq!(
1389            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1390            "file.png"
1391        );
1392
1393        // Test fragment stripping
1394        assert_eq!(
1395            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1396            "file.md"
1397        );
1398        assert_eq!(
1399            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1400            "file.md"
1401        );
1402
1403        // Test both query and fragment (query comes first, per RFC 3986)
1404        assert_eq!(
1405            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1406            "file.md"
1407        );
1408
1409        // Test no query or fragment
1410        assert_eq!(
1411            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1412            "file.png"
1413        );
1414
1415        // Test with path
1416        assert_eq!(
1417            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1418            "path/to/image.png"
1419        );
1420        assert_eq!(
1421            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1422            "path/to/image.png"
1423        );
1424
1425        // Edge case: fragment before query (non-standard but possible)
1426        assert_eq!(
1427            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1428            "file.md"
1429        );
1430    }
1431
1432    #[test]
1433    fn test_url_decode() {
1434        // Simple space encoding
1435        assert_eq!(
1436            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1437            "penguin with space.jpg"
1438        );
1439
1440        // Path with encoded spaces
1441        assert_eq!(
1442            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1443            "assets/my file name.png"
1444        );
1445
1446        // Multiple encoded characters
1447        assert_eq!(
1448            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1449            "hello world!.md"
1450        );
1451
1452        // Lowercase hex
1453        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1454
1455        // Uppercase hex
1456        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1457
1458        // Mixed case hex
1459        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1460
1461        // No encoding - return as-is
1462        assert_eq!(
1463            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1464            "normal-file.md"
1465        );
1466
1467        // Incomplete percent encoding - leave as-is
1468        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1469
1470        // Percent at end - leave as-is
1471        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1472
1473        // Invalid hex digits - leave as-is
1474        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1475
1476        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
1477        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1478
1479        // Empty string
1480        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1481
1482        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
1483        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1484
1485        // Multiple consecutive encoded characters
1486        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
1487
1488        // Encoded path separators
1489        assert_eq!(
1490            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1491            "path/to/file.md"
1492        );
1493
1494        // Mixed encoded and non-encoded
1495        assert_eq!(
1496            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1497            "hello world/foo bar.md"
1498        );
1499
1500        // Special characters that are commonly encoded
1501        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1502
1503        // Percent at position that looks like encoding but isn't valid
1504        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1505    }
1506
1507    #[test]
1508    fn test_url_encoded_filenames() {
1509        // Create a temporary directory for test files
1510        let temp_dir = tempdir().unwrap();
1511        let base_path = temp_dir.path();
1512
1513        // Create a file with spaces in the name
1514        let file_with_spaces = base_path.join("penguin with space.jpg");
1515        File::create(&file_with_spaces)
1516            .unwrap()
1517            .write_all(b"image data")
1518            .unwrap();
1519
1520        // Create a subdirectory with spaces
1521        let subdir = base_path.join("my images");
1522        std::fs::create_dir(&subdir).unwrap();
1523        let nested_file = subdir.join("photo 1.png");
1524        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1525
1526        // Test content with URL-encoded links
1527        let content = r#"
1528# Test Document with URL-Encoded Links
1529
1530![Penguin](penguin%20with%20space.jpg)
1531![Photo](my%20images/photo%201.png)
1532![Missing](missing%20file.jpg)
1533"#;
1534
1535        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1536
1537        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538        let result = rule.check(&ctx).unwrap();
1539
1540        // Should only have one warning for the missing file
1541        assert_eq!(
1542            result.len(),
1543            1,
1544            "Should only warn about missing%20file.jpg. Got: {result:?}"
1545        );
1546        assert!(
1547            result[0].message.contains("missing%20file.jpg"),
1548            "Warning should mention the URL-encoded filename"
1549        );
1550    }
1551
1552    #[test]
1553    fn test_external_urls() {
1554        let rule = MD057ExistingRelativeLinks::new();
1555
1556        // Common web protocols
1557        assert!(rule.is_external_url("https://example.com"));
1558        assert!(rule.is_external_url("http://example.com"));
1559        assert!(rule.is_external_url("ftp://example.com"));
1560        assert!(rule.is_external_url("www.example.com"));
1561        assert!(rule.is_external_url("example.com"));
1562
1563        // Special URI schemes
1564        assert!(rule.is_external_url("file:///path/to/file"));
1565        assert!(rule.is_external_url("smb://server/share"));
1566        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1567        assert!(rule.is_external_url("mailto:user@example.com"));
1568        assert!(rule.is_external_url("tel:+1234567890"));
1569        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1570        assert!(rule.is_external_url("javascript:void(0)"));
1571        assert!(rule.is_external_url("ssh://git@github.com/repo"));
1572        assert!(rule.is_external_url("git://github.com/repo.git"));
1573
1574        // Email addresses without mailto: protocol
1575        // These are clearly not file links and should be skipped
1576        assert!(rule.is_external_url("user@example.com"));
1577        assert!(rule.is_external_url("steering@kubernetes.io"));
1578        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1579        assert!(rule.is_external_url("user_name@sub.domain.com"));
1580        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1581
1582        // Template variables should be skipped (not checked as relative links)
1583        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
1584        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
1585        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
1586        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
1587        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
1588        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
1589
1590        // Absolute paths are NOT external (handled separately via is_absolute_path)
1591        // By default they are ignored, but can be configured to warn
1592        assert!(!rule.is_external_url("/api/v1/users"));
1593        assert!(!rule.is_external_url("/blog/2024/release.html"));
1594        assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1595        assert!(!rule.is_external_url("/pkg/runtime"));
1596        assert!(!rule.is_external_url("/doc/go1compat"));
1597        assert!(!rule.is_external_url("/index.html"));
1598        assert!(!rule.is_external_url("/assets/logo.png"));
1599
1600        // But is_absolute_path should detect them
1601        assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1602        assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1603        assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1604        assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1605        assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1606
1607        // Framework path aliases should be skipped (resolved by build tools)
1608        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
1609        assert!(rule.is_external_url("~/assets/image.png"));
1610        assert!(rule.is_external_url("~/components/Button.vue"));
1611        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
1612
1613        // @ prefix (common in Vue, webpack, Vite aliases)
1614        assert!(rule.is_external_url("@/components/Header.vue"));
1615        assert!(rule.is_external_url("@images/photo.jpg"));
1616        assert!(rule.is_external_url("@assets/styles.css"));
1617
1618        // Relative paths should NOT be external (should be validated)
1619        assert!(!rule.is_external_url("./relative/path.md"));
1620        assert!(!rule.is_external_url("relative/path.md"));
1621        assert!(!rule.is_external_url("../parent/path.md"));
1622    }
1623
1624    #[test]
1625    fn test_dot_com_only_skips_bare_domains() {
1626        let rule = MD057ExistingRelativeLinks::new();
1627
1628        // Bare domains ending in .com are treated as external (skipped).
1629        assert!(rule.is_external_url("example.com"));
1630        assert!(rule.is_external_url("sub.example.com"));
1631
1632        // A relative path that merely ends in ".com" must NOT be skipped:
1633        // it contains a path separator, so it is a relative file reference
1634        // that should be validated, not assumed external.
1635        assert!(!rule.is_external_url("../../vendor.com"));
1636        assert!(!rule.is_external_url("./vendor.com"));
1637        assert!(!rule.is_external_url("docs/vendor.com"));
1638    }
1639
1640    #[test]
1641    fn test_framework_path_aliases() {
1642        // Create a temporary directory for test files
1643        let temp_dir = tempdir().unwrap();
1644        let base_path = temp_dir.path();
1645
1646        // Test content with framework path aliases (should all be skipped)
1647        let content = r#"
1648# Framework Path Aliases
1649
1650![Image 1](~/assets/penguin.jpg)
1651![Image 2](~assets/logo.svg)
1652![Image 3](@images/photo.jpg)
1653![Image 4](@/components/icon.svg)
1654[Link](@/pages/about.md)
1655
1656This is a [real missing link](missing.md) that should be flagged.
1657"#;
1658
1659        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1660
1661        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662        let result = rule.check(&ctx).unwrap();
1663
1664        // Should only have one warning for the real missing link
1665        assert_eq!(
1666            result.len(),
1667            1,
1668            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1669        );
1670        assert!(
1671            result[0].message.contains("missing.md"),
1672            "Warning should be for missing.md"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_url_decode_security_path_traversal() {
1678        // Ensure URL decoding doesn't enable path traversal attacks
1679        // The decoded path is still validated against the base path
1680        let temp_dir = tempdir().unwrap();
1681        let base_path = temp_dir.path();
1682
1683        // Create a file in the temp directory
1684        let file_in_base = base_path.join("safe.md");
1685        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1686
1687        // Test with encoded path traversal attempt
1688        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1689        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1690        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1691        let content = r#"
1692[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1693[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1694[Safe link](safe.md)
1695"#;
1696
1697        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1698
1699        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700        let result = rule.check(&ctx).unwrap();
1701
1702        // The traversal attempts should still be flagged as missing
1703        // (they don't exist relative to base_path after decoding)
1704        assert_eq!(
1705            result.len(),
1706            2,
1707            "Should have warnings for traversal attempts. Got: {result:?}"
1708        );
1709    }
1710
1711    #[test]
1712    fn test_url_encoded_utf8_filenames() {
1713        // Test with actual UTF-8 encoded filenames
1714        let temp_dir = tempdir().unwrap();
1715        let base_path = temp_dir.path();
1716
1717        // Create files with unicode names
1718        let cafe_file = base_path.join("café.md");
1719        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1720
1721        let content = r#"
1722[Café link](caf%C3%A9.md)
1723[Missing unicode](r%C3%A9sum%C3%A9.md)
1724"#;
1725
1726        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1727
1728        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1729        let result = rule.check(&ctx).unwrap();
1730
1731        // Should only warn about the missing file
1732        assert_eq!(
1733            result.len(),
1734            1,
1735            "Should only warn about missing résumé.md. Got: {result:?}"
1736        );
1737        assert!(
1738            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1739            "Warning should mention the URL-encoded filename"
1740        );
1741    }
1742
1743    #[test]
1744    fn test_url_encoded_emoji_filenames() {
1745        // URL-encoded emoji paths should be correctly resolved
1746        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1747        let temp_dir = tempdir().unwrap();
1748        let base_path = temp_dir.path();
1749
1750        // Create directory with emoji in name: 👤 Personal
1751        let emoji_dir = base_path.join("👤 Personal");
1752        std::fs::create_dir(&emoji_dir).unwrap();
1753
1754        // Create file in that directory: TV Shows.md
1755        let file_path = emoji_dir.join("TV Shows.md");
1756        File::create(&file_path)
1757            .unwrap()
1758            .write_all(b"# TV Shows\n\nContent here.")
1759            .unwrap();
1760
1761        // Test content with URL-encoded emoji link
1762        // %F0%9F%91%A4 = 👤, %20 = space
1763        let content = r#"
1764# Test Document
1765
1766[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1767[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1768"#;
1769
1770        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1771
1772        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773        let result = rule.check(&ctx).unwrap();
1774
1775        // Should only warn about the missing file, not the valid emoji path
1776        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1777        assert!(
1778            result[0].message.contains("Missing.md"),
1779            "Warning should be for Missing.md, got: {}",
1780            result[0].message
1781        );
1782    }
1783
1784    #[test]
1785    fn test_no_warnings_without_base_path() {
1786        let rule = MD057ExistingRelativeLinks::new();
1787        let content = "[Link](missing.md)";
1788
1789        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790        let result = rule.check(&ctx).unwrap();
1791        assert!(result.is_empty(), "Should have no warnings without base path");
1792    }
1793
1794    #[test]
1795    fn test_existing_and_missing_links() {
1796        // Create a temporary directory for test files
1797        let temp_dir = tempdir().unwrap();
1798        let base_path = temp_dir.path();
1799
1800        // Create an existing file
1801        let exists_path = base_path.join("exists.md");
1802        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1803
1804        // Verify the file exists
1805        assert!(exists_path.exists(), "exists.md should exist for this test");
1806
1807        // Create test content with both existing and missing links
1808        let content = r#"
1809# Test Document
1810
1811[Valid Link](exists.md)
1812[Invalid Link](missing.md)
1813[External Link](https://example.com)
1814[Media Link](image.jpg)
1815        "#;
1816
1817        // Initialize rule with the base path (default: check all files including media)
1818        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1819
1820        // Test the rule
1821        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822        let result = rule.check(&ctx).unwrap();
1823
1824        // Should have two warnings: missing.md and image.jpg (both don't exist)
1825        assert_eq!(result.len(), 2);
1826        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1827        assert!(messages.iter().any(|m| m.contains("missing.md")));
1828        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1829    }
1830
1831    #[test]
1832    fn test_angle_bracket_links() {
1833        // Create a temporary directory for test files
1834        let temp_dir = tempdir().unwrap();
1835        let base_path = temp_dir.path();
1836
1837        // Create an existing file
1838        let exists_path = base_path.join("exists.md");
1839        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1840
1841        // Create test content with angle bracket links
1842        let content = r#"
1843# Test Document
1844
1845[Valid Link](<exists.md>)
1846[Invalid Link](<missing.md>)
1847[External Link](<https://example.com>)
1848    "#;
1849
1850        // Test with default settings
1851        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1852
1853        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854        let result = rule.check(&ctx).unwrap();
1855
1856        // Should have one warning for missing.md
1857        assert_eq!(result.len(), 1, "Should have exactly one warning");
1858        assert!(
1859            result[0].message.contains("missing.md"),
1860            "Warning should mention missing.md"
1861        );
1862    }
1863
1864    #[test]
1865    fn test_angle_bracket_links_with_parens() {
1866        // Create a temporary directory for test files
1867        let temp_dir = tempdir().unwrap();
1868        let base_path = temp_dir.path();
1869
1870        // Create directory structure with parentheses in path
1871        let app_dir = base_path.join("app");
1872        std::fs::create_dir(&app_dir).unwrap();
1873        let upload_dir = app_dir.join("(upload)");
1874        std::fs::create_dir(&upload_dir).unwrap();
1875        let page_file = upload_dir.join("page.tsx");
1876        File::create(&page_file)
1877            .unwrap()
1878            .write_all(b"export default function Page() {}")
1879            .unwrap();
1880
1881        // Create test content with angle bracket links containing parentheses
1882        let content = r#"
1883# Test Document with Paths Containing Parens
1884
1885[Upload Page](<app/(upload)/page.tsx>)
1886[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1887[Missing](<app/(missing)/file.md>)
1888"#;
1889
1890        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1891
1892        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893        let result = rule.check(&ctx).unwrap();
1894
1895        // Should only have one warning for the missing file
1896        assert_eq!(
1897            result.len(),
1898            1,
1899            "Should have exactly one warning for missing file. Got: {result:?}"
1900        );
1901        assert!(
1902            result[0].message.contains("app/(missing)/file.md"),
1903            "Warning should mention app/(missing)/file.md"
1904        );
1905    }
1906
1907    #[test]
1908    fn test_all_file_types_checked() {
1909        // Create a temporary directory for test files
1910        let temp_dir = tempdir().unwrap();
1911        let base_path = temp_dir.path();
1912
1913        // Create a test with various file types - all should be checked
1914        let content = r#"
1915[Image Link](image.jpg)
1916[Video Link](video.mp4)
1917[Markdown Link](document.md)
1918[PDF Link](file.pdf)
1919"#;
1920
1921        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1922
1923        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1924        let result = rule.check(&ctx).unwrap();
1925
1926        // Should warn about all missing files regardless of extension
1927        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1928    }
1929
1930    #[test]
1931    fn test_code_span_detection() {
1932        let rule = MD057ExistingRelativeLinks::new();
1933
1934        // Create a temporary directory for test files
1935        let temp_dir = tempdir().unwrap();
1936        let base_path = temp_dir.path();
1937
1938        let rule = rule.with_path(base_path);
1939
1940        // Test with document structure
1941        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1942
1943        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944        let result = rule.check(&ctx).unwrap();
1945
1946        // Should only find the real link, not the one in code
1947        assert_eq!(result.len(), 1, "Should only flag the real link");
1948        assert!(result[0].message.contains("nonexistent.md"));
1949    }
1950
1951    #[test]
1952    fn test_inline_code_spans() {
1953        // Create a temporary directory for test files
1954        let temp_dir = tempdir().unwrap();
1955        let base_path = temp_dir.path();
1956
1957        // Create test content with links in inline code spans
1958        let content = r#"
1959# Test Document
1960
1961This is a normal link: [Link](missing.md)
1962
1963This is a code span with a link: `[Link](another-missing.md)`
1964
1965Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1966
1967    "#;
1968
1969        // Initialize rule with the base path
1970        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1971
1972        // Test the rule
1973        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1974        let result = rule.check(&ctx).unwrap();
1975
1976        // Should only have warning for the normal link, not for links in code spans
1977        assert_eq!(result.len(), 1, "Should have exactly one warning");
1978        assert!(
1979            result[0].message.contains("missing.md"),
1980            "Warning should be for missing.md"
1981        );
1982        assert!(
1983            !result.iter().any(|w| w.message.contains("another-missing.md")),
1984            "Should not warn about link in code span"
1985        );
1986        assert!(
1987            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1988            "Should not warn about link in inline code"
1989        );
1990    }
1991
1992    #[test]
1993    fn test_extensionless_link_resolution() {
1994        // Create a temporary directory for test files
1995        let temp_dir = tempdir().unwrap();
1996        let base_path = temp_dir.path();
1997
1998        // Create a markdown file WITHOUT specifying .md extension in the link
1999        let page_path = base_path.join("page.md");
2000        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2001
2002        // Test content with extensionless link that should resolve to page.md
2003        let content = r#"
2004# Test Document
2005
2006[Link without extension](page)
2007[Link with extension](page.md)
2008[Missing link](nonexistent)
2009"#;
2010
2011        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2012
2013        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014        let result = rule.check(&ctx).unwrap();
2015
2016        // Should only have warning for nonexistent link
2017        // Both "page" and "page.md" should resolve to the same file
2018        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2019        assert!(
2020            result[0].message.contains("nonexistent"),
2021            "Warning should be for 'nonexistent' not 'page'"
2022        );
2023    }
2024
2025    // Cross-file validation tests
2026    #[test]
2027    fn test_cross_file_scope() {
2028        let rule = MD057ExistingRelativeLinks::new();
2029        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2030    }
2031
2032    #[test]
2033    fn test_contribute_to_index_extracts_markdown_links() {
2034        let rule = MD057ExistingRelativeLinks::new();
2035        let content = r#"
2036# Document
2037
2038[Link to docs](./docs/guide.md)
2039[Link with fragment](./other.md#section)
2040[External link](https://example.com)
2041[Image link](image.png)
2042[Media file](video.mp4)
2043"#;
2044
2045        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046        let mut index = FileIndex::new();
2047        rule.contribute_to_index(&ctx, &mut index);
2048
2049        // Should only index markdown file links
2050        assert_eq!(index.cross_file_links.len(), 2);
2051
2052        // Check first link
2053        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2054        assert_eq!(index.cross_file_links[0].fragment, "");
2055
2056        // Check second link (with fragment)
2057        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2058        assert_eq!(index.cross_file_links[1].fragment, "section");
2059    }
2060
2061    #[test]
2062    fn test_contribute_to_index_skips_external_and_anchors() {
2063        let rule = MD057ExistingRelativeLinks::new();
2064        let content = r#"
2065# Document
2066
2067[External](https://example.com)
2068[Another external](http://example.org)
2069[Fragment only](#section)
2070[FTP link](ftp://files.example.com)
2071[Mail link](mailto:test@example.com)
2072[WWW link](www.example.com)
2073"#;
2074
2075        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2076        let mut index = FileIndex::new();
2077        rule.contribute_to_index(&ctx, &mut index);
2078
2079        // Should not index any of these
2080        assert_eq!(index.cross_file_links.len(), 0);
2081    }
2082
2083    #[test]
2084    fn test_cross_file_check_valid_link() {
2085        use crate::workspace_index::WorkspaceIndex;
2086
2087        let rule = MD057ExistingRelativeLinks::new();
2088
2089        // Create a workspace index with the target file
2090        let mut workspace_index = WorkspaceIndex::new();
2091        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2092
2093        // Create file index with a link to an existing file
2094        let mut file_index = FileIndex::new();
2095        file_index.add_cross_file_link(CrossFileLinkIndex {
2096            target_path: "guide.md".to_string(),
2097            fragment: "".to_string(),
2098            line: 5,
2099            column: 1,
2100        });
2101
2102        // Run cross-file check from docs/index.md
2103        let warnings = rule
2104            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2105            .unwrap();
2106
2107        // Should have no warnings - file exists
2108        assert!(warnings.is_empty());
2109    }
2110
2111    #[test]
2112    fn test_cross_file_check_missing_link() {
2113        // cross_file_check delegates all validation to check() to avoid duplicates.
2114        // It always returns empty — the per-file check() path is authoritative.
2115        use crate::workspace_index::WorkspaceIndex;
2116
2117        let rule = MD057ExistingRelativeLinks::new();
2118        let workspace_index = WorkspaceIndex::new();
2119
2120        let mut file_index = FileIndex::new();
2121        file_index.add_cross_file_link(CrossFileLinkIndex {
2122            target_path: "missing.md".to_string(),
2123            fragment: "".to_string(),
2124            line: 5,
2125            column: 1,
2126        });
2127
2128        let warnings = rule
2129            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2130            .unwrap();
2131
2132        // cross_file_check defers to check(); it produces no warnings of its own.
2133        assert!(
2134            warnings.is_empty(),
2135            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2136        );
2137    }
2138
2139    #[test]
2140    fn test_cross_file_check_parent_path() {
2141        use crate::workspace_index::WorkspaceIndex;
2142
2143        let rule = MD057ExistingRelativeLinks::new();
2144
2145        // Create a workspace index with the target file at the root
2146        let mut workspace_index = WorkspaceIndex::new();
2147        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2148
2149        // Create file index with a parent path link
2150        let mut file_index = FileIndex::new();
2151        file_index.add_cross_file_link(CrossFileLinkIndex {
2152            target_path: "../readme.md".to_string(),
2153            fragment: "".to_string(),
2154            line: 5,
2155            column: 1,
2156        });
2157
2158        // Run cross-file check from docs/guide.md
2159        let warnings = rule
2160            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2161            .unwrap();
2162
2163        // Should have no warnings - file exists at normalized path
2164        assert!(warnings.is_empty());
2165    }
2166
2167    #[test]
2168    fn test_cross_file_check_html_link_with_md_source() {
2169        // Test that .html links are accepted when corresponding .md source exists
2170        // This supports mdBook and similar doc generators that compile .md to .html
2171        use crate::workspace_index::WorkspaceIndex;
2172
2173        let rule = MD057ExistingRelativeLinks::new();
2174
2175        // Create a workspace index with the .md source file
2176        let mut workspace_index = WorkspaceIndex::new();
2177        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2178
2179        // Create file index with an .html link (from another rule like MD051)
2180        let mut file_index = FileIndex::new();
2181        file_index.add_cross_file_link(CrossFileLinkIndex {
2182            target_path: "guide.html".to_string(),
2183            fragment: "section".to_string(),
2184            line: 10,
2185            column: 5,
2186        });
2187
2188        // Run cross-file check from docs/index.md
2189        let warnings = rule
2190            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2191            .unwrap();
2192
2193        // Should have no warnings - .md source exists for the .html link
2194        assert!(
2195            warnings.is_empty(),
2196            "Expected no warnings for .html link with .md source, got: {warnings:?}"
2197        );
2198    }
2199
2200    #[test]
2201    fn test_cross_file_check_html_link_without_source() {
2202        // cross_file_check delegates all validation to check() to avoid duplicates.
2203        // Verifying that .html links without a matching .md source are caught is
2204        // already covered by test_html_link_with_md_source (check() path).
2205        use crate::workspace_index::WorkspaceIndex;
2206
2207        let rule = MD057ExistingRelativeLinks::new();
2208        let workspace_index = WorkspaceIndex::new();
2209
2210        let mut file_index = FileIndex::new();
2211        file_index.add_cross_file_link(CrossFileLinkIndex {
2212            target_path: "missing.html".to_string(),
2213            fragment: "".to_string(),
2214            line: 10,
2215            column: 5,
2216        });
2217
2218        let warnings = rule
2219            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2220            .unwrap();
2221
2222        // cross_file_check defers to check(); it produces no warnings of its own.
2223        assert!(
2224            warnings.is_empty(),
2225            "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2226        );
2227    }
2228
2229    #[test]
2230    fn test_normalize_path_function() {
2231        // Test simple cases
2232        assert_eq!(
2233            normalize_path(Path::new("docs/guide.md")),
2234            PathBuf::from("docs/guide.md")
2235        );
2236
2237        // Test current directory removal
2238        assert_eq!(
2239            normalize_path(Path::new("./docs/guide.md")),
2240            PathBuf::from("docs/guide.md")
2241        );
2242
2243        // Test parent directory resolution
2244        assert_eq!(
2245            normalize_path(Path::new("docs/sub/../guide.md")),
2246            PathBuf::from("docs/guide.md")
2247        );
2248
2249        // Test multiple parent directories
2250        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2251    }
2252
2253    #[test]
2254    fn test_html_link_with_md_source() {
2255        // Links to .html files should pass if corresponding .md source exists
2256        let temp_dir = tempdir().unwrap();
2257        let base_path = temp_dir.path();
2258
2259        // Create guide.md (source file)
2260        let md_file = base_path.join("guide.md");
2261        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2262
2263        let content = r#"
2264[Read the guide](guide.html)
2265[Also here](getting-started.html)
2266"#;
2267
2268        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2269        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2270        let result = rule.check(&ctx).unwrap();
2271
2272        // guide.html passes (guide.md exists), getting-started.html fails
2273        assert_eq!(
2274            result.len(),
2275            1,
2276            "Should only warn about missing source. Got: {result:?}"
2277        );
2278        assert!(result[0].message.contains("getting-started.html"));
2279    }
2280
2281    #[test]
2282    fn test_htm_link_with_md_source() {
2283        // .htm extension should also check for markdown source
2284        let temp_dir = tempdir().unwrap();
2285        let base_path = temp_dir.path();
2286
2287        let md_file = base_path.join("page.md");
2288        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2289
2290        let content = "[Page](page.htm)";
2291
2292        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2293        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2294        let result = rule.check(&ctx).unwrap();
2295
2296        assert!(
2297            result.is_empty(),
2298            "Should not warn when .md source exists for .htm link"
2299        );
2300    }
2301
2302    #[test]
2303    fn test_html_link_finds_various_markdown_extensions() {
2304        // Should find .mdx, .markdown, etc. as source files
2305        let temp_dir = tempdir().unwrap();
2306        let base_path = temp_dir.path();
2307
2308        File::create(base_path.join("doc.md")).unwrap();
2309        File::create(base_path.join("tutorial.mdx")).unwrap();
2310        File::create(base_path.join("guide.markdown")).unwrap();
2311
2312        let content = r#"
2313[Doc](doc.html)
2314[Tutorial](tutorial.html)
2315[Guide](guide.html)
2316"#;
2317
2318        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2319        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320        let result = rule.check(&ctx).unwrap();
2321
2322        assert!(
2323            result.is_empty(),
2324            "Should find all markdown variants as source files. Got: {result:?}"
2325        );
2326    }
2327
2328    #[test]
2329    fn test_html_link_in_subdirectory() {
2330        // Should find markdown source in subdirectories
2331        let temp_dir = tempdir().unwrap();
2332        let base_path = temp_dir.path();
2333
2334        let docs_dir = base_path.join("docs");
2335        std::fs::create_dir(&docs_dir).unwrap();
2336        File::create(docs_dir.join("guide.md"))
2337            .unwrap()
2338            .write_all(b"# Guide")
2339            .unwrap();
2340
2341        let content = "[Guide](docs/guide.html)";
2342
2343        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2344        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345        let result = rule.check(&ctx).unwrap();
2346
2347        assert!(result.is_empty(), "Should find markdown source in subdirectory");
2348    }
2349
2350    #[test]
2351    fn test_absolute_path_skipped_in_check() {
2352        // Test that absolute paths are skipped during link validation
2353        // This fixes the bug where /pkg/runtime was being flagged
2354        let temp_dir = tempdir().unwrap();
2355        let base_path = temp_dir.path();
2356
2357        let content = r#"
2358# Test Document
2359
2360[Go Runtime](/pkg/runtime)
2361[Go Runtime with Fragment](/pkg/runtime#section)
2362[API Docs](/api/v1/users)
2363[Blog Post](/blog/2024/release.html)
2364[React Hook](/react/hooks/use-state.html)
2365"#;
2366
2367        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2368        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369        let result = rule.check(&ctx).unwrap();
2370
2371        // Should have NO warnings - all absolute paths should be skipped
2372        assert!(
2373            result.is_empty(),
2374            "Absolute paths should be skipped. Got warnings: {result:?}"
2375        );
2376    }
2377
2378    #[test]
2379    fn test_absolute_path_skipped_in_cross_file_check() {
2380        // Test that absolute paths are skipped in cross_file_check()
2381        use crate::workspace_index::WorkspaceIndex;
2382
2383        let rule = MD057ExistingRelativeLinks::new();
2384
2385        // Create an empty workspace index (no files exist)
2386        let workspace_index = WorkspaceIndex::new();
2387
2388        // Create file index with absolute path links (should be skipped)
2389        let mut file_index = FileIndex::new();
2390        file_index.add_cross_file_link(CrossFileLinkIndex {
2391            target_path: "/pkg/runtime.md".to_string(),
2392            fragment: "".to_string(),
2393            line: 5,
2394            column: 1,
2395        });
2396        file_index.add_cross_file_link(CrossFileLinkIndex {
2397            target_path: "/api/v1/users.md".to_string(),
2398            fragment: "section".to_string(),
2399            line: 10,
2400            column: 1,
2401        });
2402
2403        // Run cross-file check
2404        let warnings = rule
2405            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2406            .unwrap();
2407
2408        // Should have NO warnings - absolute paths should be skipped
2409        assert!(
2410            warnings.is_empty(),
2411            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2412        );
2413    }
2414
2415    #[test]
2416    fn test_protocol_relative_url_not_skipped() {
2417        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
2418        // They should still be caught by is_external_url() though
2419        let temp_dir = tempdir().unwrap();
2420        let base_path = temp_dir.path();
2421
2422        let content = r#"
2423# Test Document
2424
2425[External](//example.com/page)
2426[Another](//cdn.example.com/asset.js)
2427"#;
2428
2429        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2430        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2431        let result = rule.check(&ctx).unwrap();
2432
2433        // Should have NO warnings - protocol-relative URLs are external and should be skipped
2434        assert!(
2435            result.is_empty(),
2436            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2437        );
2438    }
2439
2440    #[test]
2441    fn test_email_addresses_skipped() {
2442        // Test that email addresses without mailto: are skipped
2443        // These are clearly not file links (the @ symbol is definitive)
2444        let temp_dir = tempdir().unwrap();
2445        let base_path = temp_dir.path();
2446
2447        let content = r#"
2448# Test Document
2449
2450[Contact](user@example.com)
2451[Steering](steering@kubernetes.io)
2452[Support](john.doe+filter@company.co.uk)
2453[User](user_name@sub.domain.com)
2454"#;
2455
2456        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2457        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2458        let result = rule.check(&ctx).unwrap();
2459
2460        // Should have NO warnings - email addresses are clearly not file links and should be skipped
2461        assert!(
2462            result.is_empty(),
2463            "Email addresses should be skipped. Got warnings: {result:?}"
2464        );
2465    }
2466
2467    #[test]
2468    fn test_email_addresses_vs_file_paths() {
2469        // Test that email addresses (anything with @) are skipped
2470        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
2471        let temp_dir = tempdir().unwrap();
2472        let base_path = temp_dir.path();
2473
2474        let content = r#"
2475# Test Document
2476
2477[Email](user@example.com)  <!-- Should be skipped (email) -->
2478[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
2479[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
2480"#;
2481
2482        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2483        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484        let result = rule.check(&ctx).unwrap();
2485
2486        // All should be skipped - anything with @ is treated as an email
2487        assert!(
2488            result.is_empty(),
2489            "All email addresses should be skipped. Got: {result:?}"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_diagnostic_position_accuracy() {
2495        // Test that diagnostics point to the URL, not the link text
2496        let temp_dir = tempdir().unwrap();
2497        let base_path = temp_dir.path();
2498
2499        // Position markers:     0         1         2         3
2500        //                       0123456789012345678901234567890123456789
2501        let content = "prefix [text](missing.md) suffix";
2502        //             The URL "missing.md" starts at 0-indexed position 14
2503        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
2504
2505        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2506        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507        let result = rule.check(&ctx).unwrap();
2508
2509        assert_eq!(result.len(), 1, "Should have exactly one warning");
2510        assert_eq!(result[0].line, 1, "Should be on line 1");
2511        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2512        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2513    }
2514
2515    #[test]
2516    fn test_diagnostic_position_non_ascii_link() {
2517        // Issue #670: columns are character offsets, not byte offsets. The CJK
2518        // prefix is multi-byte in UTF-8, so a byte offset over-counts the column.
2519        let temp_dir = tempdir().unwrap();
2520        let base_path = temp_dir.path();
2521
2522        // Character columns: 1:你 2:好 3:你 4:好 5:[ 6:你 7:好 8:] 9:( 10:n ...
2523        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 10
2524        // and ends past character column 21, i.e. end_column 22.
2525        let content = "你好你好[你好](not-exist.md) bar";
2526
2527        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2528        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2529        let result = rule.check(&ctx).unwrap();
2530
2531        assert_eq!(result.len(), 1, "Should have exactly one warning");
2532        assert_eq!(result[0].line, 1, "Should be on line 1");
2533        assert_eq!(
2534            result[0].column, 10,
2535            "Column must be a character offset, not a byte offset"
2536        );
2537        assert_eq!(result[0].end_column, 22, "End column must be character-based");
2538    }
2539
2540    #[test]
2541    fn test_diagnostic_position_angle_brackets() {
2542        // Test position accuracy with angle bracket links
2543        let temp_dir = tempdir().unwrap();
2544        let base_path = temp_dir.path();
2545
2546        // Position markers:     0         1         2
2547        //                       012345678901234567890
2548        let content = "[link](<missing.md>)";
2549        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
2550
2551        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2552        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2553        let result = rule.check(&ctx).unwrap();
2554
2555        assert_eq!(result.len(), 1, "Should have exactly one warning");
2556        assert_eq!(result[0].line, 1, "Should be on line 1");
2557        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2558    }
2559
2560    #[test]
2561    fn test_diagnostic_position_multiline() {
2562        // Test that line numbers are correct for links on different lines
2563        let temp_dir = tempdir().unwrap();
2564        let base_path = temp_dir.path();
2565
2566        let content = r#"# Title
2567Some text on line 2
2568[link on line 3](missing1.md)
2569More text
2570[link on line 5](missing2.md)"#;
2571
2572        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2573        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2574        let result = rule.check(&ctx).unwrap();
2575
2576        assert_eq!(result.len(), 2, "Should have two warnings");
2577
2578        // First warning should be on line 3
2579        assert_eq!(result[0].line, 3, "First warning should be on line 3");
2580        assert!(result[0].message.contains("missing1.md"));
2581
2582        // Second warning should be on line 5
2583        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2584        assert!(result[1].message.contains("missing2.md"));
2585    }
2586
2587    #[test]
2588    fn test_diagnostic_position_with_spaces() {
2589        // Test position with URLs that have spaces in parentheses
2590        let temp_dir = tempdir().unwrap();
2591        let base_path = temp_dir.path();
2592
2593        let content = "[link]( missing.md )";
2594        //             0123456789012345678901
2595        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
2596        //             which is 1-indexed column 9
2597
2598        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2599        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600        let result = rule.check(&ctx).unwrap();
2601
2602        assert_eq!(result.len(), 1, "Should have exactly one warning");
2603        // The regex captures the URL without leading/trailing spaces
2604        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2605    }
2606
2607    #[test]
2608    fn test_diagnostic_position_image() {
2609        // Test that image diagnostics also have correct positions
2610        let temp_dir = tempdir().unwrap();
2611        let base_path = temp_dir.path();
2612
2613        let content = "![alt text](missing.jpg)";
2614
2615        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2616        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2617        let result = rule.check(&ctx).unwrap();
2618
2619        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2620        assert_eq!(result[0].line, 1);
2621        // Images use start_col from the parser, which should point to the URL
2622        assert!(result[0].column > 0, "Should have valid column position");
2623        assert!(result[0].message.contains("missing.jpg"));
2624    }
2625
2626    #[test]
2627    fn test_diagnostic_position_non_ascii_image() {
2628        // Issue #670: image columns are character offsets, not byte offsets.
2629        let temp_dir = tempdir().unwrap();
2630        let base_path = temp_dir.path();
2631
2632        // Character columns: 1:你 2:好 3:你 4:好 5:! 6:[ 7:你 8:好 9:] 10:( 11:n ...
2633        // The image syntax starts at the '!' which is 1-indexed character column 5.
2634        let content = "你好你好![你好](not-exist.png)";
2635
2636        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2637        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2638        let result = rule.check(&ctx).unwrap();
2639
2640        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2641        assert_eq!(result[0].line, 1, "Should be on line 1");
2642        assert_eq!(
2643            result[0].column, 5,
2644            "Column must be a character offset, not a byte offset"
2645        );
2646        assert!(result[0].message.contains("not-exist.png"));
2647    }
2648
2649    #[test]
2650    fn test_diagnostic_position_non_ascii_reference_def() {
2651        // Issue #670: reference-definition columns are character offsets. A
2652        // multi-byte label shifts the URL's byte offset away from its character
2653        // column.
2654        let temp_dir = tempdir().unwrap();
2655        let base_path = temp_dir.path();
2656
2657        // Character columns: 1:[ 2:你 3:好 4:] 5:: 6:space 7:n ...
2658        // The URL "not-exist.md" (12 chars) starts at 1-indexed character column 7.
2659        let content = "[你好]: not-exist.md";
2660
2661        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2662        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2663        let result = rule.check(&ctx).unwrap();
2664
2665        assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2666        assert_eq!(result[0].line, 1, "Should be on line 1");
2667        assert_eq!(
2668            result[0].column, 7,
2669            "Column must be a character offset, not a byte offset"
2670        );
2671        assert_eq!(result[0].end_column, 19, "End column must be character-based");
2672    }
2673
2674    #[test]
2675    fn test_wikilinks_skipped() {
2676        // Wikilinks should not trigger MD057 warnings
2677        // They use a different linking system (e.g., Obsidian, wiki software)
2678        let temp_dir = tempdir().unwrap();
2679        let base_path = temp_dir.path();
2680
2681        let content = r#"# Test Document
2682
2683[[Microsoft#Windows OS]]
2684[[SomePage]]
2685[[Page With Spaces]]
2686[[path/to/page#section]]
2687[[page|Display Text]]
2688
2689This is a [real missing link](missing.md) that should be flagged.
2690"#;
2691
2692        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2693        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2694        let result = rule.check(&ctx).unwrap();
2695
2696        // Should only warn about the regular markdown link, not wikilinks
2697        assert_eq!(
2698            result.len(),
2699            1,
2700            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2701        );
2702        assert!(
2703            result[0].message.contains("missing.md"),
2704            "Warning should be for missing.md, not wikilinks"
2705        );
2706    }
2707
2708    #[test]
2709    fn test_wikilinks_not_added_to_index() {
2710        // Wikilinks should not be added to the cross-file link index
2711        let temp_dir = tempdir().unwrap();
2712        let base_path = temp_dir.path();
2713
2714        let content = r#"# Test Document
2715
2716[[Microsoft#Windows OS]]
2717[[SomePage#section]]
2718[Regular Link](other.md)
2719"#;
2720
2721        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2722        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2723
2724        let mut file_index = FileIndex::new();
2725        rule.contribute_to_index(&ctx, &mut file_index);
2726
2727        // Should only have the regular markdown link (if it's a markdown file)
2728        // Wikilinks should not be added
2729        let cross_file_links = &file_index.cross_file_links;
2730        assert_eq!(
2731            cross_file_links.len(),
2732            1,
2733            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2734        );
2735        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2736    }
2737
2738    #[test]
2739    fn test_reference_definition_missing_file() {
2740        // Reference definitions [ref]: ./path.md should be checked
2741        let temp_dir = tempdir().unwrap();
2742        let base_path = temp_dir.path();
2743
2744        let content = r#"# Test Document
2745
2746[test]: ./missing.md
2747[example]: ./nonexistent.html
2748
2749Use [test] and [example] here.
2750"#;
2751
2752        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2753        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754        let result = rule.check(&ctx).unwrap();
2755
2756        // Should have warnings for both reference definitions
2757        assert_eq!(
2758            result.len(),
2759            2,
2760            "Should have warnings for missing reference definition targets. Got: {result:?}"
2761        );
2762        assert!(
2763            result.iter().any(|w| w.message.contains("missing.md")),
2764            "Should warn about missing.md"
2765        );
2766        assert!(
2767            result.iter().any(|w| w.message.contains("nonexistent.html")),
2768            "Should warn about nonexistent.html"
2769        );
2770    }
2771
2772    #[test]
2773    fn test_reference_definition_existing_file() {
2774        // Reference definitions to existing files should NOT trigger warnings
2775        let temp_dir = tempdir().unwrap();
2776        let base_path = temp_dir.path();
2777
2778        // Create an existing file
2779        let exists_path = base_path.join("exists.md");
2780        File::create(&exists_path)
2781            .unwrap()
2782            .write_all(b"# Existing file")
2783            .unwrap();
2784
2785        let content = r#"# Test Document
2786
2787[test]: ./exists.md
2788
2789Use [test] here.
2790"#;
2791
2792        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2793        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2794        let result = rule.check(&ctx).unwrap();
2795
2796        // Should have NO warnings since the file exists
2797        assert!(
2798            result.is_empty(),
2799            "Should not warn about existing file. Got: {result:?}"
2800        );
2801    }
2802
2803    #[test]
2804    fn test_reference_definition_external_url_skipped() {
2805        // Reference definitions with external URLs should be skipped
2806        let temp_dir = tempdir().unwrap();
2807        let base_path = temp_dir.path();
2808
2809        let content = r#"# Test Document
2810
2811[google]: https://google.com
2812[example]: http://example.org
2813[mail]: mailto:test@example.com
2814[ftp]: ftp://files.example.com
2815[local]: ./missing.md
2816
2817Use [google], [example], [mail], [ftp], [local] here.
2818"#;
2819
2820        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2821        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2822        let result = rule.check(&ctx).unwrap();
2823
2824        // Should only warn about the local missing file, not external URLs
2825        assert_eq!(
2826            result.len(),
2827            1,
2828            "Should only warn about local missing file. Got: {result:?}"
2829        );
2830        assert!(
2831            result[0].message.contains("missing.md"),
2832            "Warning should be for missing.md"
2833        );
2834    }
2835
2836    #[test]
2837    fn test_reference_definition_fragment_only_skipped() {
2838        // Reference definitions with fragment-only URLs should be skipped
2839        let temp_dir = tempdir().unwrap();
2840        let base_path = temp_dir.path();
2841
2842        let content = r#"# Test Document
2843
2844[section]: #my-section
2845
2846Use [section] here.
2847"#;
2848
2849        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2850        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2851        let result = rule.check(&ctx).unwrap();
2852
2853        // Should have NO warnings for fragment-only links
2854        assert!(
2855            result.is_empty(),
2856            "Should not warn about fragment-only reference. Got: {result:?}"
2857        );
2858    }
2859
2860    #[test]
2861    fn test_reference_definition_column_position() {
2862        // Test that column position points to the URL in the reference definition
2863        let temp_dir = tempdir().unwrap();
2864        let base_path = temp_dir.path();
2865
2866        // Position markers:     0         1         2
2867        //                       0123456789012345678901
2868        let content = "[ref]: ./missing.md";
2869        //             The URL "./missing.md" starts at 0-indexed position 7
2870        //             which is 1-indexed column 8
2871
2872        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2873        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2874        let result = rule.check(&ctx).unwrap();
2875
2876        assert_eq!(result.len(), 1, "Should have exactly one warning");
2877        assert_eq!(result[0].line, 1, "Should be on line 1");
2878        assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2879    }
2880
2881    #[test]
2882    fn test_reference_definition_html_with_md_source() {
2883        // Reference definitions to .html files should pass if corresponding .md source exists
2884        let temp_dir = tempdir().unwrap();
2885        let base_path = temp_dir.path();
2886
2887        // Create guide.md (source file)
2888        let md_file = base_path.join("guide.md");
2889        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2890
2891        let content = r#"# Test Document
2892
2893[guide]: ./guide.html
2894[missing]: ./missing.html
2895
2896Use [guide] and [missing] here.
2897"#;
2898
2899        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2900        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2901        let result = rule.check(&ctx).unwrap();
2902
2903        // guide.html passes (guide.md exists), missing.html fails
2904        assert_eq!(
2905            result.len(),
2906            1,
2907            "Should only warn about missing source. Got: {result:?}"
2908        );
2909        assert!(result[0].message.contains("missing.html"));
2910    }
2911
2912    #[test]
2913    fn test_reference_definition_url_encoded() {
2914        // Reference definitions with URL-encoded paths should be decoded before checking
2915        let temp_dir = tempdir().unwrap();
2916        let base_path = temp_dir.path();
2917
2918        // Create a file with spaces in the name
2919        let file_with_spaces = base_path.join("file with spaces.md");
2920        File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2921
2922        let content = r#"# Test Document
2923
2924[spaces]: ./file%20with%20spaces.md
2925[missing]: ./missing%20file.md
2926
2927Use [spaces] and [missing] here.
2928"#;
2929
2930        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2931        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2932        let result = rule.check(&ctx).unwrap();
2933
2934        // Should only warn about the missing file
2935        assert_eq!(
2936            result.len(),
2937            1,
2938            "Should only warn about missing URL-encoded file. Got: {result:?}"
2939        );
2940        assert!(result[0].message.contains("missing%20file.md"));
2941    }
2942
2943    #[test]
2944    fn test_inline_and_reference_both_checked() {
2945        // Both inline links and reference definitions should be checked
2946        let temp_dir = tempdir().unwrap();
2947        let base_path = temp_dir.path();
2948
2949        let content = r#"# Test Document
2950
2951[inline link](./inline-missing.md)
2952[ref]: ./ref-missing.md
2953
2954Use [ref] here.
2955"#;
2956
2957        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2958        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2959        let result = rule.check(&ctx).unwrap();
2960
2961        // Should warn about both the inline link and the reference definition
2962        assert_eq!(
2963            result.len(),
2964            2,
2965            "Should warn about both inline and reference links. Got: {result:?}"
2966        );
2967        assert!(
2968            result.iter().any(|w| w.message.contains("inline-missing.md")),
2969            "Should warn about inline-missing.md"
2970        );
2971        assert!(
2972            result.iter().any(|w| w.message.contains("ref-missing.md")),
2973            "Should warn about ref-missing.md"
2974        );
2975    }
2976
2977    #[test]
2978    fn test_footnote_definitions_not_flagged() {
2979        // Regression test for issue #286: footnote definitions should not be
2980        // treated as reference definitions and flagged as broken links
2981        let rule = MD057ExistingRelativeLinks::default();
2982
2983        let content = r#"# Title
2984
2985A footnote[^1].
2986
2987[^1]: [link](https://www.google.com).
2988"#;
2989
2990        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2991        let result = rule.check(&ctx).unwrap();
2992
2993        assert!(
2994            result.is_empty(),
2995            "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2996        );
2997    }
2998
2999    #[test]
3000    fn test_footnote_with_relative_link_inside() {
3001        // Footnotes containing relative links should not be checked
3002        // (the footnote content is not a URL, it's content that may contain links)
3003        let rule = MD057ExistingRelativeLinks::default();
3004
3005        let content = r#"# Title
3006
3007See the footnote[^1].
3008
3009[^1]: Check out [this file](./existing.md) for more info.
3010[^2]: Also see [missing](./does-not-exist.md).
3011"#;
3012
3013        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3014        let result = rule.check(&ctx).unwrap();
3015
3016        // The inline links INSIDE footnotes should be checked (./existing.md, ./does-not-exist.md)
3017        // but the footnote definition itself should not be treated as a reference definition
3018        // Note: This test verifies that [^1]: and [^2]: are not parsed as ref defs with
3019        // URLs like "[this file](./existing.md)" or "[missing](./does-not-exist.md)"
3020        for warning in &result {
3021            assert!(
3022                !warning.message.contains("[this file]"),
3023                "Footnote content should not be treated as URL: {warning:?}"
3024            );
3025            assert!(
3026                !warning.message.contains("[missing]"),
3027                "Footnote content should not be treated as URL: {warning:?}"
3028            );
3029        }
3030    }
3031
3032    #[test]
3033    fn test_mixed_footnotes_and_reference_definitions() {
3034        // Ensure regular reference definitions are still checked while footnotes are skipped
3035        let temp_dir = tempdir().unwrap();
3036        let base_path = temp_dir.path();
3037
3038        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3039
3040        let content = r#"# Title
3041
3042A footnote[^1] and a [ref link][myref].
3043
3044[^1]: This is a footnote with [link](https://example.com).
3045
3046[myref]: ./missing-file.md "This should be checked"
3047"#;
3048
3049        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050        let result = rule.check(&ctx).unwrap();
3051
3052        // Should only warn about the regular reference definition, not the footnote
3053        assert_eq!(
3054            result.len(),
3055            1,
3056            "Should only warn about the regular reference definition. Got: {result:?}"
3057        );
3058        assert!(
3059            result[0].message.contains("missing-file.md"),
3060            "Should warn about missing-file.md in reference definition"
3061        );
3062    }
3063
3064    #[test]
3065    fn test_absolute_links_ignore_by_default() {
3066        // By default, absolute links are ignored (not validated)
3067        let temp_dir = tempdir().unwrap();
3068        let base_path = temp_dir.path();
3069
3070        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3071
3072        let content = r#"# Links
3073
3074[API docs](/api/v1/users)
3075[Blog post](/blog/2024/release.html)
3076![Logo](/assets/logo.png)
3077
3078[ref]: /docs/reference.md
3079"#;
3080
3081        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3082        let result = rule.check(&ctx).unwrap();
3083
3084        // No warnings - absolute links are ignored by default
3085        assert!(
3086            result.is_empty(),
3087            "Absolute links should be ignored by default. Got: {result:?}"
3088        );
3089    }
3090
3091    #[test]
3092    fn test_absolute_links_warn_config() {
3093        // When configured to warn, absolute links should generate warnings
3094        let temp_dir = tempdir().unwrap();
3095        let base_path = temp_dir.path();
3096
3097        let config = MD057Config {
3098            absolute_links: AbsoluteLinksOption::Warn,
3099            ..Default::default()
3100        };
3101        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3102
3103        let content = r#"# Links
3104
3105[API docs](/api/v1/users)
3106[Blog post](/blog/2024/release.html)
3107"#;
3108
3109        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3110        let result = rule.check(&ctx).unwrap();
3111
3112        // Should have 2 warnings for the 2 absolute links
3113        assert_eq!(
3114            result.len(),
3115            2,
3116            "Should warn about both absolute links. Got: {result:?}"
3117        );
3118        assert!(
3119            result[0].message.contains("cannot be validated locally"),
3120            "Warning should explain why: {}",
3121            result[0].message
3122        );
3123        assert!(
3124            result[0].message.contains("/api/v1/users"),
3125            "Warning should include the link path"
3126        );
3127    }
3128
3129    #[test]
3130    fn test_absolute_links_warn_images() {
3131        // Images with absolute paths should also warn when configured
3132        let temp_dir = tempdir().unwrap();
3133        let base_path = temp_dir.path();
3134
3135        let config = MD057Config {
3136            absolute_links: AbsoluteLinksOption::Warn,
3137            ..Default::default()
3138        };
3139        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3140
3141        let content = r#"# Images
3142
3143![Logo](/assets/logo.png)
3144"#;
3145
3146        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3147        let result = rule.check(&ctx).unwrap();
3148
3149        assert_eq!(
3150            result.len(),
3151            1,
3152            "Should warn about absolute image path. Got: {result:?}"
3153        );
3154        assert!(
3155            result[0].message.contains("/assets/logo.png"),
3156            "Warning should include the image path"
3157        );
3158    }
3159
3160    #[test]
3161    fn test_absolute_links_warn_reference_definitions() {
3162        // Reference definitions with absolute paths should also warn when configured
3163        let temp_dir = tempdir().unwrap();
3164        let base_path = temp_dir.path();
3165
3166        let config = MD057Config {
3167            absolute_links: AbsoluteLinksOption::Warn,
3168            ..Default::default()
3169        };
3170        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3171
3172        let content = r#"# Reference
3173
3174See the [docs][ref].
3175
3176[ref]: /docs/reference.md
3177"#;
3178
3179        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3180        let result = rule.check(&ctx).unwrap();
3181
3182        assert_eq!(
3183            result.len(),
3184            1,
3185            "Should warn about absolute reference definition. Got: {result:?}"
3186        );
3187        assert!(
3188            result[0].message.contains("/docs/reference.md"),
3189            "Warning should include the reference path"
3190        );
3191    }
3192
3193    #[test]
3194    fn test_search_paths_inline_link() {
3195        let temp_dir = tempdir().unwrap();
3196        let base_path = temp_dir.path();
3197
3198        // Create an "assets" directory with an image
3199        let assets_dir = base_path.join("assets");
3200        std::fs::create_dir_all(&assets_dir).unwrap();
3201        std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3202
3203        let config = MD057Config {
3204            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3205            ..Default::default()
3206        };
3207        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3208
3209        let content = "# Test\n\n[Photo](photo.png)\n";
3210        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3211        let result = rule.check(&ctx).unwrap();
3212
3213        assert!(
3214            result.is_empty(),
3215            "Should find photo.png via search-paths. Got: {result:?}"
3216        );
3217    }
3218
3219    #[test]
3220    fn test_search_paths_image() {
3221        let temp_dir = tempdir().unwrap();
3222        let base_path = temp_dir.path();
3223
3224        let assets_dir = base_path.join("attachments");
3225        std::fs::create_dir_all(&assets_dir).unwrap();
3226        std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3227
3228        let config = MD057Config {
3229            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3230            ..Default::default()
3231        };
3232        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3233
3234        let content = "# Test\n\n![Diagram](diagram.svg)\n";
3235        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3236        let result = rule.check(&ctx).unwrap();
3237
3238        assert!(
3239            result.is_empty(),
3240            "Should find diagram.svg via search-paths. Got: {result:?}"
3241        );
3242    }
3243
3244    #[test]
3245    fn test_search_paths_reference_definition() {
3246        let temp_dir = tempdir().unwrap();
3247        let base_path = temp_dir.path();
3248
3249        let assets_dir = base_path.join("images");
3250        std::fs::create_dir_all(&assets_dir).unwrap();
3251        std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3252
3253        let config = MD057Config {
3254            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3255            ..Default::default()
3256        };
3257        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3258
3259        let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3260        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3261        let result = rule.check(&ctx).unwrap();
3262
3263        assert!(
3264            result.is_empty(),
3265            "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3266        );
3267    }
3268
3269    #[test]
3270    fn test_search_paths_still_warns_when_truly_missing() {
3271        let temp_dir = tempdir().unwrap();
3272        let base_path = temp_dir.path();
3273
3274        let assets_dir = base_path.join("assets");
3275        std::fs::create_dir_all(&assets_dir).unwrap();
3276
3277        let config = MD057Config {
3278            search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3279            ..Default::default()
3280        };
3281        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3282
3283        let content = "# Test\n\n![Missing](nonexistent.png)\n";
3284        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3285        let result = rule.check(&ctx).unwrap();
3286
3287        assert_eq!(
3288            result.len(),
3289            1,
3290            "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3291        );
3292    }
3293
3294    #[test]
3295    fn test_search_paths_nonexistent_directory() {
3296        let temp_dir = tempdir().unwrap();
3297        let base_path = temp_dir.path();
3298
3299        let config = MD057Config {
3300            search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3301            ..Default::default()
3302        };
3303        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3304
3305        let content = "# Test\n\n![Missing](photo.png)\n";
3306        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3307        let result = rule.check(&ctx).unwrap();
3308
3309        assert_eq!(
3310            result.len(),
3311            1,
3312            "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3313        );
3314    }
3315
3316    #[test]
3317    fn test_obsidian_attachment_folder_named() {
3318        let temp_dir = tempdir().unwrap();
3319        let vault = temp_dir.path().join("vault");
3320        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3321        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3322        std::fs::create_dir_all(vault.join("notes")).unwrap();
3323
3324        std::fs::write(
3325            vault.join(".obsidian/app.json"),
3326            r#"{"attachmentFolderPath": "Attachments"}"#,
3327        )
3328        .unwrap();
3329        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3330
3331        let notes_dir = vault.join("notes");
3332        let source_file = notes_dir.join("test.md");
3333        std::fs::write(&source_file, "# Test\n\n![Photo](photo.png)\n").unwrap();
3334
3335        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3336
3337        let content = "# Test\n\n![Photo](photo.png)\n";
3338        let ctx =
3339            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3340        let result = rule.check(&ctx).unwrap();
3341
3342        assert!(
3343            result.is_empty(),
3344            "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3345        );
3346    }
3347
3348    #[test]
3349    fn test_obsidian_attachment_same_folder_as_file() {
3350        let temp_dir = tempdir().unwrap();
3351        let vault = temp_dir.path().join("vault-rf");
3352        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3353        std::fs::create_dir_all(vault.join("notes")).unwrap();
3354
3355        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3356
3357        // Image in the same directory as the file — default behavior, no extra search needed
3358        let notes_dir = vault.join("notes");
3359        let source_file = notes_dir.join("test.md");
3360        std::fs::write(&source_file, "placeholder").unwrap();
3361        std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3362
3363        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3364
3365        let content = "# Test\n\n![Photo](photo.png)\n";
3366        let ctx =
3367            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3368        let result = rule.check(&ctx).unwrap();
3369
3370        assert!(
3371            result.is_empty(),
3372            "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3373        );
3374    }
3375
3376    #[test]
3377    fn test_obsidian_not_triggered_without_obsidian_flavor() {
3378        let temp_dir = tempdir().unwrap();
3379        let vault = temp_dir.path().join("vault-nf");
3380        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3381        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3382        std::fs::create_dir_all(vault.join("notes")).unwrap();
3383
3384        std::fs::write(
3385            vault.join(".obsidian/app.json"),
3386            r#"{"attachmentFolderPath": "Attachments"}"#,
3387        )
3388        .unwrap();
3389        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3390
3391        let notes_dir = vault.join("notes");
3392        let source_file = notes_dir.join("test.md");
3393        std::fs::write(&source_file, "placeholder").unwrap();
3394
3395        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3396
3397        let content = "# Test\n\n![Photo](photo.png)\n";
3398        // Standard flavor — NOT Obsidian
3399        let ctx =
3400            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3401        let result = rule.check(&ctx).unwrap();
3402
3403        assert_eq!(
3404            result.len(),
3405            1,
3406            "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3407        );
3408    }
3409
3410    #[test]
3411    fn test_search_paths_combined_with_obsidian() {
3412        let temp_dir = tempdir().unwrap();
3413        let vault = temp_dir.path().join("vault-combo");
3414        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3415        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3416        std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3417        std::fs::create_dir_all(vault.join("notes")).unwrap();
3418
3419        std::fs::write(
3420            vault.join(".obsidian/app.json"),
3421            r#"{"attachmentFolderPath": "Attachments"}"#,
3422        )
3423        .unwrap();
3424        std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3425        std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3426
3427        let notes_dir = vault.join("notes");
3428        let source_file = notes_dir.join("test.md");
3429        std::fs::write(&source_file, "placeholder").unwrap();
3430
3431        let extra_assets_dir = vault.join("extra-assets");
3432        let config = MD057Config {
3433            search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3434            ..Default::default()
3435        };
3436        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&notes_dir);
3437
3438        // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths
3439        let content = "# Test\n\n![Photo](photo.png)\n\n![Diagram](diagram.svg)\n";
3440        let ctx =
3441            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3442        let result = rule.check(&ctx).unwrap();
3443
3444        assert!(
3445            result.is_empty(),
3446            "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3447        );
3448    }
3449
3450    #[test]
3451    fn test_obsidian_attachment_subfolder_under_file() {
3452        let temp_dir = tempdir().unwrap();
3453        let vault = temp_dir.path().join("vault-sub");
3454        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3455        std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3456
3457        std::fs::write(
3458            vault.join(".obsidian/app.json"),
3459            r#"{"attachmentFolderPath": "./assets"}"#,
3460        )
3461        .unwrap();
3462        std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3463
3464        let notes_dir = vault.join("notes");
3465        let source_file = notes_dir.join("test.md");
3466        std::fs::write(&source_file, "placeholder").unwrap();
3467
3468        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3469
3470        let content = "# Test\n\n![Photo](photo.png)\n";
3471        let ctx =
3472            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3473        let result = rule.check(&ctx).unwrap();
3474
3475        assert!(
3476            result.is_empty(),
3477            "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3478        );
3479    }
3480
3481    #[test]
3482    fn test_obsidian_attachment_vault_root() {
3483        let temp_dir = tempdir().unwrap();
3484        let vault = temp_dir.path().join("vault-root");
3485        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3486        std::fs::create_dir_all(vault.join("notes")).unwrap();
3487
3488        // Empty string = vault root
3489        std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3490        std::fs::write(vault.join("photo.png"), "fake").unwrap();
3491
3492        let notes_dir = vault.join("notes");
3493        let source_file = notes_dir.join("test.md");
3494        std::fs::write(&source_file, "placeholder").unwrap();
3495
3496        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(&notes_dir);
3497
3498        let content = "# Test\n\n![Photo](photo.png)\n";
3499        let ctx =
3500            crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3501        let result = rule.check(&ctx).unwrap();
3502
3503        assert!(
3504            result.is_empty(),
3505            "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3506        );
3507    }
3508
3509    #[test]
3510    fn test_search_paths_multiple_directories() {
3511        let temp_dir = tempdir().unwrap();
3512        let base_path = temp_dir.path();
3513
3514        let dir_a = base_path.join("dir-a");
3515        let dir_b = base_path.join("dir-b");
3516        std::fs::create_dir_all(&dir_a).unwrap();
3517        std::fs::create_dir_all(&dir_b).unwrap();
3518        std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3519        std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3520
3521        let config = MD057Config {
3522            search_paths: vec![
3523                dir_a.to_string_lossy().into_owned(),
3524                dir_b.to_string_lossy().into_owned(),
3525            ],
3526            ..Default::default()
3527        };
3528        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3529
3530        let content = "# Test\n\n![A](alpha.png)\n\n![B](beta.png)\n";
3531        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3532        let result = rule.check(&ctx).unwrap();
3533
3534        assert!(
3535            result.is_empty(),
3536            "Should find files across multiple search paths. Got: {result:?}"
3537        );
3538    }
3539
3540    #[test]
3541    fn test_cross_file_check_with_search_paths() {
3542        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3543
3544        let temp_dir = tempdir().unwrap();
3545        let base_path = temp_dir.path();
3546
3547        // Create docs directory with a markdown target in a search path
3548        let docs_dir = base_path.join("docs");
3549        std::fs::create_dir_all(&docs_dir).unwrap();
3550        std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3551
3552        let config = MD057Config {
3553            search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3554            ..Default::default()
3555        };
3556        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3557
3558        let file_path = base_path.join("README.md");
3559        std::fs::write(&file_path, "# Readme\n").unwrap();
3560
3561        let mut file_index = FileIndex::default();
3562        file_index.cross_file_links.push(CrossFileLinkIndex {
3563            target_path: "guide.md".to_string(),
3564            fragment: String::new(),
3565            line: 3,
3566            column: 1,
3567        });
3568
3569        let workspace_index = WorkspaceIndex::new();
3570
3571        let result = rule
3572            .cross_file_check(&file_path, &file_index, &workspace_index)
3573            .unwrap();
3574
3575        assert!(
3576            result.is_empty(),
3577            "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3578        );
3579    }
3580
3581    #[test]
3582    fn test_cross_file_check_with_obsidian_flavor() {
3583        use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3584
3585        let temp_dir = tempdir().unwrap();
3586        let vault = temp_dir.path().join("vault-xf");
3587        std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3588        std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3589        std::fs::create_dir_all(vault.join("notes")).unwrap();
3590
3591        std::fs::write(
3592            vault.join(".obsidian/app.json"),
3593            r#"{"attachmentFolderPath": "Attachments"}"#,
3594        )
3595        .unwrap();
3596        std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3597
3598        let notes_dir = vault.join("notes");
3599        let file_path = notes_dir.join("test.md");
3600        std::fs::write(&file_path, "placeholder").unwrap();
3601
3602        let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3603            .with_path(&notes_dir)
3604            .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3605
3606        let mut file_index = FileIndex::default();
3607        file_index.cross_file_links.push(CrossFileLinkIndex {
3608            target_path: "ref.md".to_string(),
3609            fragment: String::new(),
3610            line: 3,
3611            column: 1,
3612        });
3613
3614        let workspace_index = WorkspaceIndex::new();
3615
3616        let result = rule
3617            .cross_file_check(&file_path, &file_index, &workspace_index)
3618            .unwrap();
3619
3620        assert!(
3621            result.is_empty(),
3622            "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3623        );
3624    }
3625
3626    #[test]
3627    fn test_check_clears_stale_cache() {
3628        // Verify that check() resets the file existence cache so stale entries from
3629        // a previous lint cycle do not suppress valid warnings.
3630        let temp_dir = tempdir().unwrap();
3631        let base_path = temp_dir.path();
3632
3633        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3634
3635        // Seed the cache with a stale "exists" entry for a file that is NOT on disk.
3636        let phantom_path = base_path.join("phantom.md");
3637        {
3638            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3639            cache.insert(phantom_path.clone(), true);
3640        }
3641
3642        let content = "[phantom](phantom.md)\n";
3643        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3644        let warnings = rule.check(&ctx).unwrap();
3645
3646        // check() must reset the cache; stale "exists=true" must not suppress the warning.
3647        assert_eq!(
3648            warnings.len(),
3649            1,
3650            "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3651        );
3652        assert!(warnings[0].message.contains("phantom.md"));
3653    }
3654
3655    #[test]
3656    fn test_check_does_not_carry_over_cache_between_runs() {
3657        // Two consecutive check() calls should each start with a fresh cache.
3658        let temp_dir = tempdir().unwrap();
3659        let base_path = temp_dir.path();
3660
3661        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3662
3663        let content = "[missing](nonexistent.md)\n";
3664        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3665
3666        // First run: file doesn't exist — warning expected.
3667        let warnings_1 = rule.check(&ctx).unwrap();
3668        assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3669
3670        // Inject a stale "exists = true" entry for the resolved path.
3671        let nonexistent_path = base_path.join("nonexistent.md");
3672        {
3673            let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3674            cache.insert(nonexistent_path.clone(), true);
3675        }
3676
3677        // Second run: cache says file exists, but check() should reset it first.
3678        let warnings_2 = rule.check(&ctx).unwrap();
3679        assert_eq!(
3680            warnings_2.len(),
3681            1,
3682            "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3683        );
3684    }
3685
3686    // --- Bug #631: duplicate warnings for broken relative links ---
3687
3688    /// Regression test: a single broken relative link must produce exactly one
3689    /// warning across both check() and cross_file_check(). Previously, each
3690    /// code path emitted an identical warning independently, causing duplicates.
3691    #[test]
3692    fn test_no_duplicate_warnings_for_broken_relative_link() {
3693        use crate::workspace_index::WorkspaceIndex;
3694
3695        let temp_dir = tempdir().unwrap();
3696        let base_path = temp_dir.path();
3697
3698        // The broken link target does NOT exist on disk.
3699        let source_file = base_path.join("index.md");
3700        std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3701
3702        let content = "[broken](does/not/exist.md)\n";
3703
3704        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3705
3706        // Collect warnings from check() (per-file path)
3707        let ctx = crate::lint_context::LintContext::new(
3708            content,
3709            crate::config::MarkdownFlavor::Standard,
3710            Some(source_file.clone()),
3711        );
3712        let check_warnings = rule.check(&ctx).unwrap();
3713
3714        // Collect warnings from cross_file_check() (workspace-index path)
3715        let mut file_index = FileIndex::new();
3716        rule.contribute_to_index(&ctx, &mut file_index);
3717        let workspace_index = WorkspaceIndex::new();
3718        let cross_warnings = rule
3719            .cross_file_check(&source_file, &file_index, &workspace_index)
3720            .unwrap();
3721
3722        let total = check_warnings.len() + cross_warnings.len();
3723        assert_eq!(
3724            total, 1,
3725            "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3726             check={check_warnings:?}, cross={cross_warnings:?}"
3727        );
3728    }
3729
3730    // --- Bug #632: absolute directory links incorrectly flagged ---
3731
3732    /// With absolute-links = "relative_to_roots", links to existing targets must
3733    /// be accepted for all four cases: {relative, absolute} x {file, directory}.
3734    #[test]
3735    fn test_absolute_dir_link_accepted_relative_to_roots() {
3736        let temp_dir = tempdir().unwrap();
3737        let root = temp_dir.path();
3738
3739        // Create directory `d` with a file inside (but no index.md)
3740        let dir_d = root.join("d");
3741        std::fs::create_dir_all(&dir_d).unwrap();
3742        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3743
3744        // Content exercises all four matrix cells:
3745        //   relative file, relative dir, absolute file, absolute dir
3746        let content = "\
3747[absolute dir](/d)\n\
3748[relative dir](d)\n\
3749[absolute file](/d/foo.md)\n\
3750[relative file](d/foo.md)\n";
3751
3752        let config = MD057Config {
3753            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3754            roots: vec![],
3755            ..Default::default()
3756        };
3757        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3758
3759        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3760        let result = rule.check(&ctx).unwrap();
3761
3762        assert!(
3763            result.is_empty(),
3764            "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3765        );
3766    }
3767
3768    /// A directory link with a trailing slash and no index.md should be reported
3769    /// as invalid under relative_to_roots (docs-convention: trailing slash implies index.md).
3770    #[test]
3771    fn test_absolute_trailing_slash_dir_link_requires_index() {
3772        let temp_dir = tempdir().unwrap();
3773        let root = temp_dir.path();
3774
3775        // Create directory `d` WITHOUT index.md
3776        let dir_d = root.join("d");
3777        std::fs::create_dir_all(&dir_d).unwrap();
3778        std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3779
3780        // Trailing slash signals "this is a directory index" — index.md must exist.
3781        let content = "[dir with slash](/d/)\n";
3782
3783        let config = MD057Config {
3784            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3785            roots: vec![],
3786            ..Default::default()
3787        };
3788        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3789
3790        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3791        let result = rule.check(&ctx).unwrap();
3792
3793        assert_eq!(
3794            result.len(),
3795            1,
3796            "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3797        );
3798    }
3799
3800    /// The docs_dir (MkDocs) variant must still flag a directory link when index.md
3801    /// is absent. This is tested via the full check() path with RelativeToDocs config
3802    /// and a real mkdocs.yml pointing at a docs dir that contains the directory target.
3803    #[test]
3804    fn test_docs_dir_variant_still_enforces_index_md() {
3805        let temp_dir = tempdir().unwrap();
3806        let root = temp_dir.path();
3807
3808        // Create a minimal mkdocs.yml pointing at a "docs" directory
3809        std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3810
3811        // Create docs/section/ WITHOUT index.md
3812        let docs_dir = root.join("docs");
3813        std::fs::create_dir_all(&docs_dir).unwrap();
3814        let section_dir = docs_dir.join("section");
3815        std::fs::create_dir_all(&section_dir).unwrap();
3816        std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3817
3818        // Create the source markdown file inside docs/
3819        let source_file = docs_dir.join("index.md");
3820        std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3821
3822        let config = MD057Config {
3823            absolute_links: AbsoluteLinksOption::RelativeToDocs,
3824            ..Default::default()
3825        };
3826        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3827
3828        let content = "[sec](/section)\n";
3829        let ctx = crate::lint_context::LintContext::new(
3830            content,
3831            crate::config::MarkdownFlavor::Standard,
3832            Some(source_file.clone()),
3833        );
3834        let result = rule.check(&ctx).unwrap();
3835
3836        // MkDocs enforces index.md for directory links, so this should be flagged.
3837        assert_eq!(
3838            result.len(),
3839            1,
3840            "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3841        );
3842        assert!(
3843            result[0].message.contains("index.md") || result[0].message.contains("section"),
3844            "Message should mention the directory or missing index.md: {}",
3845            result[0].message
3846        );
3847    }
3848
3849    /// Regression test for the edge case where a trailing-slash directory URL has a
3850    /// fragment suffix (e.g. `/guide/#intro`). After stripping the fragment, the
3851    /// decoded path is `guide/` (ends with `/`), but `is_directory_link` was computed
3852    /// from `url.ends_with('/')` which is false when the URL ends with `#intro`.
3853    /// The fix must still treat such links as directory links and require index.md.
3854    #[test]
3855    fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3856        let temp_dir = tempdir().unwrap();
3857        let root = temp_dir.path();
3858
3859        // Create directory `guide` WITHOUT index.md
3860        let guide_dir = root.join("guide");
3861        std::fs::create_dir_all(&guide_dir).unwrap();
3862        std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3863
3864        // /guide/#intro has a trailing slash before the fragment — must require index.md
3865        let content = "[guide with fragment](/guide/#intro)\n";
3866
3867        let config = MD057Config {
3868            absolute_links: AbsoluteLinksOption::RelativeToRoots,
3869            roots: vec![],
3870            ..Default::default()
3871        };
3872        let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3873        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3874        let result = rule.check(&ctx).unwrap();
3875
3876        assert_eq!(
3877            result.len(),
3878            1,
3879            "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3880        );
3881    }
3882}