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::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::element_cache::ElementCache;
8use crate::workspace_index::{CrossFileLinkIndex, FileIndex};
9use regex::Regex;
10use std::collections::HashMap;
11use std::env;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use std::sync::{Arc, Mutex};
15
16mod md057_config;
17use md057_config::MD057Config;
18
19// Thread-safe cache for file existence checks to avoid redundant filesystem operations
20static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
21    LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
22
23// Reset the file existence cache (typically between rule runs)
24fn reset_file_existence_cache() {
25    if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
26        cache.clear();
27    }
28}
29
30// Check if a file exists with caching
31fn file_exists_with_cache(path: &Path) -> bool {
32    match FILE_EXISTENCE_CACHE.lock() {
33        Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
34        Err(_) => path.exists(), // Fallback to uncached check on mutex poison
35    }
36}
37
38/// Check if a file exists, also trying markdown extensions for extensionless links.
39/// This supports wiki-style links like `[Link](page)` that resolve to `page.md`.
40fn file_exists_or_markdown_extension(path: &Path) -> bool {
41    // First, check exact path
42    if file_exists_with_cache(path) {
43        return true;
44    }
45
46    // If the path has no extension, try adding markdown extensions
47    if path.extension().is_none() {
48        for ext in MARKDOWN_EXTENSIONS {
49            // MARKDOWN_EXTENSIONS includes the dot, e.g., ".md"
50            let path_with_ext = path.with_extension(&ext[1..]);
51            if file_exists_with_cache(&path_with_ext) {
52                return true;
53            }
54        }
55    }
56
57    false
58}
59
60// Regex to match the start of a link - simplified for performance
61static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
62
63/// Regex to extract the URL from an angle-bracketed markdown link
64/// Format: `](<URL>)` or `](<URL> "title")`
65/// This handles URLs with parentheses like `](<path/(with)/parens.md>)`
66static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
67    LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
68
69/// Regex to extract the URL from a normal markdown link (without angle brackets)
70/// Format: `](URL)` or `](URL "title")`
71static URL_EXTRACT_REGEX: LazyLock<Regex> =
72    LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
73
74/// Regex to detect URLs with explicit schemes (should not be checked as relative links)
75/// Matches: scheme:// or scheme: (per RFC 3986)
76/// This covers http, https, ftp, file, smb, mailto, tel, data, macappstores, etc.
77static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
78    LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
79
80// Current working directory
81static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
82
83/// Convert a hex digit (0-9, a-f, A-F) to its numeric value.
84/// Returns None for non-hex characters.
85#[inline]
86fn hex_digit_to_value(byte: u8) -> Option<u8> {
87    match byte {
88        b'0'..=b'9' => Some(byte - b'0'),
89        b'a'..=b'f' => Some(byte - b'a' + 10),
90        b'A'..=b'F' => Some(byte - b'A' + 10),
91        _ => None,
92    }
93}
94
95/// Supported markdown file extensions
96const MARKDOWN_EXTENSIONS: &[&str] = &[
97    ".md",
98    ".markdown",
99    ".mdx",
100    ".mkd",
101    ".mkdn",
102    ".mdown",
103    ".mdwn",
104    ".qmd",
105    ".rmd",
106];
107
108/// Check if a path has a markdown extension (case-insensitive)
109#[inline]
110fn is_markdown_file(path: &str) -> bool {
111    let path_lower = path.to_lowercase();
112    MARKDOWN_EXTENSIONS.iter().any(|ext| path_lower.ends_with(ext))
113}
114
115/// Rule MD057: Existing relative links should point to valid files or directories.
116#[derive(Debug, Clone, Default)]
117pub struct MD057ExistingRelativeLinks {
118    /// Base directory for resolving relative links
119    base_path: Arc<Mutex<Option<PathBuf>>>,
120}
121
122impl MD057ExistingRelativeLinks {
123    /// Create a new instance with default settings
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    /// Set the base path for resolving relative links
129    pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
130        let path = path.as_ref();
131        let dir_path = if path.is_file() {
132            path.parent().map(|p| p.to_path_buf())
133        } else {
134            Some(path.to_path_buf())
135        };
136
137        if let Ok(mut guard) = self.base_path.lock() {
138            *guard = dir_path;
139        }
140        self
141    }
142
143    #[allow(unused_variables)]
144    pub fn from_config_struct(config: MD057Config) -> Self {
145        Self::default()
146    }
147
148    /// Check if a URL is external or should be skipped for validation.
149    ///
150    /// Returns `true` (skip validation) for:
151    /// - URLs with protocols: `https://`, `http://`, `ftp://`, `mailto:`, etc.
152    /// - Bare domains: `www.example.com`, `example.com`
153    /// - Email addresses: `user@example.com` (without `mailto:`)
154    /// - Template variables: `{{URL}}`, `{{% include %}}`
155    /// - Absolute web URL paths: `/api/docs`, `/blog/post.html`
156    ///
157    /// Returns `false` (validate) for:
158    /// - Relative filesystem paths: `./file.md`, `../parent/file.md`, `file.md`
159    #[inline]
160    fn is_external_url(&self, url: &str) -> bool {
161        if url.is_empty() {
162            return false;
163        }
164
165        // Quick checks for common external URL patterns
166        if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
167            return true;
168        }
169
170        // Skip template variables (Handlebars/Mustache/Jinja2 syntax)
171        // Examples: {{URL}}, {{#URL}}, {{> partial}}, {{% include %}}, {{ variable }}
172        if url.starts_with("{{") || url.starts_with("{%") {
173            return true;
174        }
175
176        // Simple check: if URL contains @, it's almost certainly an email address
177        // File paths with @ are extremely rare, so this is a safe heuristic
178        if url.contains('@') {
179            return true; // It's an email address, skip it
180        }
181
182        // Bare domain check (e.g., "example.com")
183        // Note: We intentionally DON'T skip all TLDs like .org, .net, etc.
184        // Links like [text](nodejs.org/path) without a protocol are broken -
185        // they'll be treated as relative paths by markdown renderers.
186        // Flagging them helps users find missing protocols.
187        // We only skip .com as a minimal safety net for the most common case.
188        if url.ends_with(".com") {
189            return true;
190        }
191
192        // Absolute URL paths (e.g., /api/docs, /blog/post.html) are treated as web paths
193        // and skipped. These are typically routes for published documentation sites,
194        // not filesystem paths that can be validated locally.
195        if url.starts_with('/') {
196            return true;
197        }
198
199        // Framework path aliases (resolved by build tools like Vite, webpack, etc.)
200        // These are not filesystem paths but module/asset aliases
201        // Examples: ~/assets/image.png, @images/photo.jpg, @/components/Button.vue
202        if url.starts_with('~') || url.starts_with('@') {
203            return true;
204        }
205
206        // All other cases (relative paths, etc.) are not external
207        false
208    }
209
210    /// Check if the URL is a fragment-only link (internal document link)
211    #[inline]
212    fn is_fragment_only_link(&self, url: &str) -> bool {
213        url.starts_with('#')
214    }
215
216    /// Decode URL percent-encoded sequences in a path.
217    /// Converts `%20` to space, `%2F` to `/`, etc.
218    /// Returns the original string if decoding fails or produces invalid UTF-8.
219    fn url_decode(path: &str) -> String {
220        // Quick check: if no percent sign, return as-is
221        if !path.contains('%') {
222            return path.to_string();
223        }
224
225        let bytes = path.as_bytes();
226        let mut result = Vec::with_capacity(bytes.len());
227        let mut i = 0;
228
229        while i < bytes.len() {
230            if bytes[i] == b'%' && i + 2 < bytes.len() {
231                // Try to parse the two hex digits following %
232                let hex1 = bytes[i + 1];
233                let hex2 = bytes[i + 2];
234                if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
235                    result.push(d1 * 16 + d2);
236                    i += 3;
237                    continue;
238                }
239            }
240            result.push(bytes[i]);
241            i += 1;
242        }
243
244        // Convert to UTF-8, falling back to original if invalid
245        String::from_utf8(result).unwrap_or_else(|_| path.to_string())
246    }
247
248    /// Strip query parameters and fragments from a URL for file existence checking.
249    /// URLs like `path/to/image.png?raw=true` or `file.md#section` should check
250    /// for `path/to/image.png` or `file.md` respectively.
251    ///
252    /// Note: In standard URLs, query parameters (`?`) come before fragments (`#`),
253    /// so we check for `?` first. If a URL has both, only the query is stripped here
254    /// (fragments are handled separately by the regex in `contribute_to_index`).
255    fn strip_query_and_fragment(url: &str) -> &str {
256        // Find the first occurrence of '?' or '#', whichever comes first
257        // This handles both standard URLs (? before #) and edge cases (# before ?)
258        let query_pos = url.find('?');
259        let fragment_pos = url.find('#');
260
261        match (query_pos, fragment_pos) {
262            (Some(q), Some(f)) => {
263                // Both exist - strip at whichever comes first
264                &url[..q.min(f)]
265            }
266            (Some(q), None) => &url[..q],
267            (None, Some(f)) => &url[..f],
268            (None, None) => url,
269        }
270    }
271
272    /// Resolve a relative link against a provided base path
273    fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
274        base_path.join(link)
275    }
276}
277
278impl Rule for MD057ExistingRelativeLinks {
279    fn name(&self) -> &'static str {
280        "MD057"
281    }
282
283    fn description(&self) -> &'static str {
284        "Relative links should point to existing files"
285    }
286
287    fn category(&self) -> RuleCategory {
288        RuleCategory::Link
289    }
290
291    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
292        ctx.content.is_empty() || !ctx.likely_has_links_or_images()
293    }
294
295    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
296        let content = ctx.content;
297
298        // Early returns for performance
299        if content.is_empty() || !content.contains('[') {
300            return Ok(Vec::new());
301        }
302
303        // Quick check for any potential links before expensive operations
304        if !content.contains("](") {
305            return Ok(Vec::new());
306        }
307
308        // Reset the file existence cache for a fresh run
309        reset_file_existence_cache();
310
311        let mut warnings = Vec::new();
312
313        // Determine base path for resolving relative links
314        // ALWAYS compute from ctx.source_file for each file - do not reuse cached base_path
315        // This ensures each file resolves links relative to its own directory
316        let base_path: Option<PathBuf> = {
317            // First check if base_path was explicitly set via with_path() (for tests)
318            let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
319            if explicit_base.is_some() {
320                explicit_base
321            } else if let Some(ref source_file) = ctx.source_file {
322                // Resolve symlinks to get the actual file location
323                // This ensures relative links are resolved from the target's directory,
324                // not the symlink's directory
325                let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
326                resolved_file
327                    .parent()
328                    .map(|p| p.to_path_buf())
329                    .or_else(|| Some(CURRENT_DIR.clone()))
330            } else {
331                // No source file available - cannot validate relative links
332                None
333            }
334        };
335
336        // If we still don't have a base path, we can't validate relative links
337        let Some(base_path) = base_path else {
338            return Ok(warnings);
339        };
340
341        // Use LintContext links instead of expensive regex parsing
342        if !ctx.links.is_empty() {
343            // Use LineIndex for correct position calculation across all line ending types
344            let line_index = &ctx.line_index;
345
346            // Create element cache once for all links
347            let element_cache = ElementCache::new(content);
348
349            // Pre-collect lines to avoid repeated line iteration
350            let lines: Vec<&str> = content.lines().collect();
351
352            for link in &ctx.links {
353                let line_idx = link.line - 1;
354                if line_idx >= lines.len() {
355                    continue;
356                }
357
358                let line = lines[line_idx];
359
360                // Quick check for link pattern in this line
361                if !line.contains("](") {
362                    continue;
363                }
364
365                // Find all links in this line using optimized regex
366                for link_match in LINK_START_REGEX.find_iter(line) {
367                    let start_pos = link_match.start();
368                    let end_pos = link_match.end();
369
370                    // Calculate absolute position using LineIndex
371                    let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
372                    let absolute_start_pos = line_start_byte + start_pos;
373
374                    // Skip if this link is in a code span
375                    if element_cache.is_in_code_span(absolute_start_pos) {
376                        continue;
377                    }
378
379                    // Find the URL part after the link text
380                    // Try angle-bracket regex first (handles URLs with parens like `<path/(with)/parens.md>`)
381                    // Then fall back to normal URL regex
382                    let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
383                        .captures_at(line, end_pos - 1)
384                        .and_then(|caps| caps.get(1).map(|g| (caps, g)))
385                        .or_else(|| {
386                            URL_EXTRACT_REGEX
387                                .captures_at(line, end_pos - 1)
388                                .and_then(|caps| caps.get(1).map(|g| (caps, g)))
389                        });
390
391                    if let Some((_caps, url_group)) = caps_and_url {
392                        let url = url_group.as_str().trim();
393
394                        // Skip empty URLs
395                        if url.is_empty() {
396                            continue;
397                        }
398
399                        // Skip external URLs, absolute paths, and fragment-only links
400                        if self.is_external_url(url) || self.is_fragment_only_link(url) {
401                            continue;
402                        }
403
404                        // Strip query parameters and fragments before checking file existence
405                        let file_path = Self::strip_query_and_fragment(url);
406
407                        // URL-decode the path to handle percent-encoded characters
408                        let decoded_path = Self::url_decode(file_path);
409
410                        // Resolve the relative link against the base path
411                        let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
412
413                        // Check if the file exists, also trying markdown extensions for extensionless links
414                        if file_exists_or_markdown_extension(&resolved_path) {
415                            continue; // File exists, no warning needed
416                        }
417
418                        // For .html/.htm links, check if a corresponding markdown source exists
419                        let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
420                            && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
421                            && let (Some(stem), Some(parent)) = (
422                                resolved_path.file_stem().and_then(|s| s.to_str()),
423                                resolved_path.parent(),
424                            ) {
425                            MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
426                                let source_path = parent.join(format!("{stem}{md_ext}"));
427                                file_exists_with_cache(&source_path)
428                            })
429                        } else {
430                            false
431                        };
432
433                        if has_md_source {
434                            continue; // Markdown source exists, link is valid
435                        }
436
437                        // File doesn't exist and no source file found
438                        // Use actual URL position from regex capture group
439                        // Note: capture group positions are absolute within the line string
440                        let url_start = url_group.start();
441                        let url_end = url_group.end();
442
443                        warnings.push(LintWarning {
444                            rule_name: Some(self.name().to_string()),
445                            line: link.line,
446                            column: url_start + 1, // 1-indexed
447                            end_line: link.line,
448                            end_column: url_end + 1, // 1-indexed
449                            message: format!("Relative link '{url}' does not exist"),
450                            severity: Severity::Error,
451                            fix: None,
452                        });
453                    }
454                }
455            }
456        }
457
458        // Also process images - they have URLs already parsed
459        for image in &ctx.images {
460            let url = image.url.as_ref();
461
462            // Skip empty URLs
463            if url.is_empty() {
464                continue;
465            }
466
467            // Skip external URLs, absolute paths, and fragment-only links
468            if self.is_external_url(url) || self.is_fragment_only_link(url) {
469                continue;
470            }
471
472            // Strip query parameters and fragments before checking file existence
473            let file_path = Self::strip_query_and_fragment(url);
474
475            // URL-decode the path to handle percent-encoded characters
476            let decoded_path = Self::url_decode(file_path);
477
478            // Resolve the relative link against the base path
479            let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
480
481            // Check if the file exists, also trying markdown extensions for extensionless links
482            if file_exists_or_markdown_extension(&resolved_path) {
483                continue; // File exists, no warning needed
484            }
485
486            // For .html/.htm links, check if a corresponding markdown source exists
487            let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
488                && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
489                && let (Some(stem), Some(parent)) = (
490                    resolved_path.file_stem().and_then(|s| s.to_str()),
491                    resolved_path.parent(),
492                ) {
493                MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
494                    let source_path = parent.join(format!("{stem}{md_ext}"));
495                    file_exists_with_cache(&source_path)
496                })
497            } else {
498                false
499            };
500
501            if has_md_source {
502                continue; // Markdown source exists, link is valid
503            }
504
505            // File doesn't exist and no source file found
506            // Images already have correct position from parser
507            warnings.push(LintWarning {
508                rule_name: Some(self.name().to_string()),
509                line: image.line,
510                column: image.start_col + 1,
511                end_line: image.line,
512                end_column: image.start_col + 1 + url.len(),
513                message: format!("Relative link '{url}' does not exist"),
514                severity: Severity::Error,
515                fix: None,
516            });
517        }
518
519        Ok(warnings)
520    }
521
522    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
523        Ok(ctx.content.to_string())
524    }
525
526    fn as_any(&self) -> &dyn std::any::Any {
527        self
528    }
529
530    fn default_config_section(&self) -> Option<(String, toml::Value)> {
531        // No configurable options for this rule
532        None
533    }
534
535    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
536    where
537        Self: Sized,
538    {
539        let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
540        Box::new(Self::from_config_struct(rule_config))
541    }
542
543    fn cross_file_scope(&self) -> CrossFileScope {
544        CrossFileScope::Workspace
545    }
546
547    fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
548        let content = ctx.content;
549
550        // Early returns for performance
551        if content.is_empty() || !content.contains("](") {
552            return;
553        }
554
555        // Pre-collect lines to avoid repeated line iteration
556        let lines: Vec<&str> = content.lines().collect();
557        let element_cache = ElementCache::new(content);
558        let line_index = &ctx.line_index;
559
560        for link in &ctx.links {
561            let line_idx = link.line - 1;
562            if line_idx >= lines.len() {
563                continue;
564            }
565
566            let line = lines[line_idx];
567            if !line.contains("](") {
568                continue;
569            }
570
571            // Find all links in this line
572            for link_match in LINK_START_REGEX.find_iter(line) {
573                let start_pos = link_match.start();
574                let end_pos = link_match.end();
575
576                // Calculate absolute position for code span detection
577                let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
578                let absolute_start_pos = line_start_byte + start_pos;
579
580                // Skip if in code span
581                if element_cache.is_in_code_span(absolute_start_pos) {
582                    continue;
583                }
584
585                // Extract the URL (group 1) and fragment (group 2)
586                // The regex separates URL and fragment: group 1 excludes #, group 2 captures #fragment
587                // Try angle-bracket regex first (handles URLs with parens)
588                let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
589                    .captures_at(line, end_pos - 1)
590                    .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
591
592                if let Some(caps) = caps_result
593                    && let Some(url_group) = caps.get(1)
594                {
595                    let file_path = url_group.as_str().trim();
596
597                    // Skip empty, external, template variables, absolute URL paths,
598                    // framework aliases, or fragment-only URLs
599                    if file_path.is_empty()
600                        || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
601                        || file_path.starts_with("www.")
602                        || file_path.starts_with('#')
603                        || file_path.starts_with("{{")
604                        || file_path.starts_with("{%")
605                        || file_path.starts_with('/')
606                        || file_path.starts_with('~')
607                        || file_path.starts_with('@')
608                    {
609                        continue;
610                    }
611
612                    // Strip query parameters before indexing (e.g., `file.md?raw=true` -> `file.md`)
613                    let file_path = Self::strip_query_and_fragment(file_path);
614
615                    // Get fragment from capture group 2 (includes # prefix)
616                    let fragment = caps.get(2).map(|m| m.as_str().trim_start_matches('#')).unwrap_or("");
617
618                    // Only index markdown file links for cross-file validation
619                    // Non-markdown files (images, media) are validated via filesystem in check()
620                    if is_markdown_file(file_path) {
621                        index.add_cross_file_link(CrossFileLinkIndex {
622                            target_path: file_path.to_string(),
623                            fragment: fragment.to_string(),
624                            line: link.line,
625                            column: url_group.start() + 1,
626                        });
627                    }
628                }
629            }
630        }
631    }
632
633    fn cross_file_check(
634        &self,
635        file_path: &Path,
636        file_index: &FileIndex,
637        workspace_index: &crate::workspace_index::WorkspaceIndex,
638    ) -> LintResult {
639        let mut warnings = Vec::new();
640
641        // Get the directory containing this file for resolving relative links
642        let file_dir = file_path.parent();
643
644        for cross_link in &file_index.cross_file_links {
645            // URL-decode the path for filesystem operations
646            // The stored path is URL-encoded (e.g., "%F0%9F%91%A4" for emoji 👤)
647            let decoded_target = Self::url_decode(&cross_link.target_path);
648
649            // Skip absolute/protocol-relative paths (web paths, not filesystem paths)
650            if decoded_target.starts_with('/') {
651                continue;
652            }
653
654            // Resolve relative path
655            let target_path = if let Some(dir) = file_dir {
656                dir.join(&decoded_target)
657            } else {
658                Path::new(&decoded_target).to_path_buf()
659            };
660
661            // Normalize the path (handle .., ., etc.)
662            let target_path = normalize_path(&target_path);
663
664            // Check if the target file exists
665            let file_exists = workspace_index.contains_file(&target_path) || target_path.exists();
666
667            if !file_exists {
668                // For .html/.htm links, check if a corresponding markdown source exists
669                // This handles doc sites (mdBook, etc.) where .md is compiled to .html
670                let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
671                    && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
672                    && let (Some(stem), Some(parent)) =
673                        (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
674                {
675                    MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
676                        let source_path = parent.join(format!("{stem}{md_ext}"));
677                        workspace_index.contains_file(&source_path) || source_path.exists()
678                    })
679                } else {
680                    false
681                };
682
683                if !has_md_source {
684                    warnings.push(LintWarning {
685                        rule_name: Some(self.name().to_string()),
686                        line: cross_link.line,
687                        column: cross_link.column,
688                        end_line: cross_link.line,
689                        end_column: cross_link.column + cross_link.target_path.len(),
690                        message: format!("Relative link '{}' does not exist", cross_link.target_path),
691                        severity: Severity::Error,
692                        fix: None,
693                    });
694                }
695            }
696        }
697
698        Ok(warnings)
699    }
700}
701
702/// Normalize a path by resolving . and .. components
703fn normalize_path(path: &Path) -> PathBuf {
704    let mut components = Vec::new();
705
706    for component in path.components() {
707        match component {
708            std::path::Component::ParentDir => {
709                // Go up one level if possible
710                if !components.is_empty() {
711                    components.pop();
712                }
713            }
714            std::path::Component::CurDir => {
715                // Skip current directory markers
716            }
717            _ => {
718                components.push(component);
719            }
720        }
721    }
722
723    components.iter().collect()
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729    use std::fs::File;
730    use std::io::Write;
731    use tempfile::tempdir;
732
733    #[test]
734    fn test_strip_query_and_fragment() {
735        // Test query parameter stripping
736        assert_eq!(
737            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
738            "file.png"
739        );
740        assert_eq!(
741            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
742            "file.png"
743        );
744        assert_eq!(
745            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
746            "file.png"
747        );
748
749        // Test fragment stripping
750        assert_eq!(
751            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
752            "file.md"
753        );
754        assert_eq!(
755            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
756            "file.md"
757        );
758
759        // Test both query and fragment (query comes first, per RFC 3986)
760        assert_eq!(
761            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
762            "file.md"
763        );
764
765        // Test no query or fragment
766        assert_eq!(
767            MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
768            "file.png"
769        );
770
771        // Test with path
772        assert_eq!(
773            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
774            "path/to/image.png"
775        );
776        assert_eq!(
777            MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
778            "path/to/image.png"
779        );
780
781        // Edge case: fragment before query (non-standard but possible)
782        assert_eq!(
783            MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
784            "file.md"
785        );
786    }
787
788    #[test]
789    fn test_url_decode() {
790        // Simple space encoding
791        assert_eq!(
792            MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
793            "penguin with space.jpg"
794        );
795
796        // Path with encoded spaces
797        assert_eq!(
798            MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
799            "assets/my file name.png"
800        );
801
802        // Multiple encoded characters
803        assert_eq!(
804            MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
805            "hello world!.md"
806        );
807
808        // Lowercase hex
809        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
810
811        // Uppercase hex
812        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
813
814        // Mixed case hex
815        assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
816
817        // No encoding - return as-is
818        assert_eq!(
819            MD057ExistingRelativeLinks::url_decode("normal-file.md"),
820            "normal-file.md"
821        );
822
823        // Incomplete percent encoding - leave as-is
824        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
825
826        // Percent at end - leave as-is
827        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
828
829        // Invalid hex digits - leave as-is
830        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
831
832        // Plus sign (should NOT be decoded - that's form encoding, not URL encoding)
833        assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
834
835        // Empty string
836        assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
837
838        // UTF-8 multi-byte characters (é = C3 A9 in UTF-8)
839        assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
840
841        // Multiple consecutive encoded characters
842        assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), "   ");
843
844        // Encoded path separators
845        assert_eq!(
846            MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
847            "path/to/file.md"
848        );
849
850        // Mixed encoded and non-encoded
851        assert_eq!(
852            MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
853            "hello world/foo bar.md"
854        );
855
856        // Special characters that are commonly encoded
857        assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
858
859        // Percent at position that looks like encoding but isn't valid
860        assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
861    }
862
863    #[test]
864    fn test_url_encoded_filenames() {
865        // Create a temporary directory for test files
866        let temp_dir = tempdir().unwrap();
867        let base_path = temp_dir.path();
868
869        // Create a file with spaces in the name
870        let file_with_spaces = base_path.join("penguin with space.jpg");
871        File::create(&file_with_spaces)
872            .unwrap()
873            .write_all(b"image data")
874            .unwrap();
875
876        // Create a subdirectory with spaces
877        let subdir = base_path.join("my images");
878        std::fs::create_dir(&subdir).unwrap();
879        let nested_file = subdir.join("photo 1.png");
880        File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
881
882        // Test content with URL-encoded links
883        let content = r#"
884# Test Document with URL-Encoded Links
885
886![Penguin](penguin%20with%20space.jpg)
887![Photo](my%20images/photo%201.png)
888![Missing](missing%20file.jpg)
889"#;
890
891        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
892
893        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894        let result = rule.check(&ctx).unwrap();
895
896        // Should only have one warning for the missing file
897        assert_eq!(
898            result.len(),
899            1,
900            "Should only warn about missing%20file.jpg. Got: {result:?}"
901        );
902        assert!(
903            result[0].message.contains("missing%20file.jpg"),
904            "Warning should mention the URL-encoded filename"
905        );
906    }
907
908    #[test]
909    fn test_external_urls() {
910        let rule = MD057ExistingRelativeLinks::new();
911
912        // Common web protocols
913        assert!(rule.is_external_url("https://example.com"));
914        assert!(rule.is_external_url("http://example.com"));
915        assert!(rule.is_external_url("ftp://example.com"));
916        assert!(rule.is_external_url("www.example.com"));
917        assert!(rule.is_external_url("example.com"));
918
919        // Special URI schemes
920        assert!(rule.is_external_url("file:///path/to/file"));
921        assert!(rule.is_external_url("smb://server/share"));
922        assert!(rule.is_external_url("macappstores://apps.apple.com/"));
923        assert!(rule.is_external_url("mailto:user@example.com"));
924        assert!(rule.is_external_url("tel:+1234567890"));
925        assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
926        assert!(rule.is_external_url("javascript:void(0)"));
927        assert!(rule.is_external_url("ssh://git@github.com/repo"));
928        assert!(rule.is_external_url("git://github.com/repo.git"));
929
930        // Email addresses without mailto: protocol
931        // These are clearly not file links and should be skipped
932        assert!(rule.is_external_url("user@example.com"));
933        assert!(rule.is_external_url("steering@kubernetes.io"));
934        assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
935        assert!(rule.is_external_url("user_name@sub.domain.com"));
936        assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
937
938        // Template variables should be skipped (not checked as relative links)
939        assert!(rule.is_external_url("{{URL}}")); // Handlebars/Mustache
940        assert!(rule.is_external_url("{{#URL}}")); // Handlebars block helper
941        assert!(rule.is_external_url("{{> partial}}")); // Handlebars partial
942        assert!(rule.is_external_url("{{ variable }}")); // Mustache with spaces
943        assert!(rule.is_external_url("{{% include %}}")); // Jinja2/Hugo shortcode
944        assert!(rule.is_external_url("{{")); // Even partial matches (regex edge case)
945
946        // Absolute web URL paths should be skipped (not validated)
947        // These are typically routes for published documentation sites
948        assert!(rule.is_external_url("/api/v1/users"));
949        assert!(rule.is_external_url("/blog/2024/release.html"));
950        assert!(rule.is_external_url("/react/hooks/use-state.html"));
951        assert!(rule.is_external_url("/pkg/runtime"));
952        assert!(rule.is_external_url("/doc/go1compat"));
953        assert!(rule.is_external_url("/index.html"));
954        assert!(rule.is_external_url("/assets/logo.png"));
955
956        // Framework path aliases should be skipped (resolved by build tools)
957        // Tilde prefix (common in Vite, Nuxt, Astro for project root)
958        assert!(rule.is_external_url("~/assets/image.png"));
959        assert!(rule.is_external_url("~/components/Button.vue"));
960        assert!(rule.is_external_url("~assets/logo.svg")); // Nuxt style without /
961
962        // @ prefix (common in Vue, webpack, Vite aliases)
963        assert!(rule.is_external_url("@/components/Header.vue"));
964        assert!(rule.is_external_url("@images/photo.jpg"));
965        assert!(rule.is_external_url("@assets/styles.css"));
966
967        // Relative paths should NOT be external (should be validated)
968        assert!(!rule.is_external_url("./relative/path.md"));
969        assert!(!rule.is_external_url("relative/path.md"));
970        assert!(!rule.is_external_url("../parent/path.md"));
971    }
972
973    #[test]
974    fn test_framework_path_aliases() {
975        // Create a temporary directory for test files
976        let temp_dir = tempdir().unwrap();
977        let base_path = temp_dir.path();
978
979        // Test content with framework path aliases (should all be skipped)
980        let content = r#"
981# Framework Path Aliases
982
983![Image 1](~/assets/penguin.jpg)
984![Image 2](~assets/logo.svg)
985![Image 3](@images/photo.jpg)
986![Image 4](@/components/icon.svg)
987[Link](@/pages/about.md)
988
989This is a [real missing link](missing.md) that should be flagged.
990"#;
991
992        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
993
994        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995        let result = rule.check(&ctx).unwrap();
996
997        // Should only have one warning for the real missing link
998        assert_eq!(
999            result.len(),
1000            1,
1001            "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1002        );
1003        assert!(
1004            result[0].message.contains("missing.md"),
1005            "Warning should be for missing.md"
1006        );
1007    }
1008
1009    #[test]
1010    fn test_url_decode_security_path_traversal() {
1011        // Ensure URL decoding doesn't enable path traversal attacks
1012        // The decoded path is still validated against the base path
1013        let temp_dir = tempdir().unwrap();
1014        let base_path = temp_dir.path();
1015
1016        // Create a file in the temp directory
1017        let file_in_base = base_path.join("safe.md");
1018        File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1019
1020        // Test with encoded path traversal attempt
1021        // Use a path that definitely won't exist on any platform (not /etc/passwd which exists on Linux)
1022        // %2F = /, so ..%2F..%2Fnonexistent%2Ffile = ../../nonexistent/file
1023        // %252F = %2F (double encoded), so ..%252F..%252F = ..%2F..%2F (literal, won't decode to ..)
1024        let content = r#"
1025[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1026[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1027[Safe link](safe.md)
1028"#;
1029
1030        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1031
1032        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033        let result = rule.check(&ctx).unwrap();
1034
1035        // The traversal attempts should still be flagged as missing
1036        // (they don't exist relative to base_path after decoding)
1037        assert_eq!(
1038            result.len(),
1039            2,
1040            "Should have warnings for traversal attempts. Got: {result:?}"
1041        );
1042    }
1043
1044    #[test]
1045    fn test_url_encoded_utf8_filenames() {
1046        // Test with actual UTF-8 encoded filenames
1047        let temp_dir = tempdir().unwrap();
1048        let base_path = temp_dir.path();
1049
1050        // Create files with unicode names
1051        let cafe_file = base_path.join("café.md");
1052        File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1053
1054        let content = r#"
1055[Café link](caf%C3%A9.md)
1056[Missing unicode](r%C3%A9sum%C3%A9.md)
1057"#;
1058
1059        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1060
1061        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062        let result = rule.check(&ctx).unwrap();
1063
1064        // Should only warn about the missing file
1065        assert_eq!(
1066            result.len(),
1067            1,
1068            "Should only warn about missing résumé.md. Got: {result:?}"
1069        );
1070        assert!(
1071            result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1072            "Warning should mention the URL-encoded filename"
1073        );
1074    }
1075
1076    #[test]
1077    fn test_url_encoded_emoji_filenames() {
1078        // URL-encoded emoji paths should be correctly resolved
1079        // 👤 = U+1F464 = F0 9F 91 A4 in UTF-8
1080        let temp_dir = tempdir().unwrap();
1081        let base_path = temp_dir.path();
1082
1083        // Create directory with emoji in name: 👤 Personal
1084        let emoji_dir = base_path.join("👤 Personal");
1085        std::fs::create_dir(&emoji_dir).unwrap();
1086
1087        // Create file in that directory: TV Shows.md
1088        let file_path = emoji_dir.join("TV Shows.md");
1089        File::create(&file_path)
1090            .unwrap()
1091            .write_all(b"# TV Shows\n\nContent here.")
1092            .unwrap();
1093
1094        // Test content with URL-encoded emoji link
1095        // %F0%9F%91%A4 = 👤, %20 = space
1096        let content = r#"
1097# Test Document
1098
1099[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1100[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1101"#;
1102
1103        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1104
1105        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106        let result = rule.check(&ctx).unwrap();
1107
1108        // Should only warn about the missing file, not the valid emoji path
1109        assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1110        assert!(
1111            result[0].message.contains("Missing.md"),
1112            "Warning should be for Missing.md, got: {}",
1113            result[0].message
1114        );
1115    }
1116
1117    #[test]
1118    fn test_no_warnings_without_base_path() {
1119        let rule = MD057ExistingRelativeLinks::new();
1120        let content = "[Link](missing.md)";
1121
1122        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123        let result = rule.check(&ctx).unwrap();
1124        assert!(result.is_empty(), "Should have no warnings without base path");
1125    }
1126
1127    #[test]
1128    fn test_existing_and_missing_links() {
1129        // Create a temporary directory for test files
1130        let temp_dir = tempdir().unwrap();
1131        let base_path = temp_dir.path();
1132
1133        // Create an existing file
1134        let exists_path = base_path.join("exists.md");
1135        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1136
1137        // Verify the file exists
1138        assert!(exists_path.exists(), "exists.md should exist for this test");
1139
1140        // Create test content with both existing and missing links
1141        let content = r#"
1142# Test Document
1143
1144[Valid Link](exists.md)
1145[Invalid Link](missing.md)
1146[External Link](https://example.com)
1147[Media Link](image.jpg)
1148        "#;
1149
1150        // Initialize rule with the base path (default: check all files including media)
1151        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1152
1153        // Test the rule
1154        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155        let result = rule.check(&ctx).unwrap();
1156
1157        // Should have two warnings: missing.md and image.jpg (both don't exist)
1158        assert_eq!(result.len(), 2);
1159        let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1160        assert!(messages.iter().any(|m| m.contains("missing.md")));
1161        assert!(messages.iter().any(|m| m.contains("image.jpg")));
1162    }
1163
1164    #[test]
1165    fn test_angle_bracket_links() {
1166        // Create a temporary directory for test files
1167        let temp_dir = tempdir().unwrap();
1168        let base_path = temp_dir.path();
1169
1170        // Create an existing file
1171        let exists_path = base_path.join("exists.md");
1172        File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1173
1174        // Create test content with angle bracket links
1175        let content = r#"
1176# Test Document
1177
1178[Valid Link](<exists.md>)
1179[Invalid Link](<missing.md>)
1180[External Link](<https://example.com>)
1181    "#;
1182
1183        // Test with default settings
1184        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1185
1186        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187        let result = rule.check(&ctx).unwrap();
1188
1189        // Should have one warning for missing.md
1190        assert_eq!(result.len(), 1, "Should have exactly one warning");
1191        assert!(
1192            result[0].message.contains("missing.md"),
1193            "Warning should mention missing.md"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_angle_bracket_links_with_parens() {
1199        // Create a temporary directory for test files
1200        let temp_dir = tempdir().unwrap();
1201        let base_path = temp_dir.path();
1202
1203        // Create directory structure with parentheses in path
1204        let app_dir = base_path.join("app");
1205        std::fs::create_dir(&app_dir).unwrap();
1206        let upload_dir = app_dir.join("(upload)");
1207        std::fs::create_dir(&upload_dir).unwrap();
1208        let page_file = upload_dir.join("page.tsx");
1209        File::create(&page_file)
1210            .unwrap()
1211            .write_all(b"export default function Page() {}")
1212            .unwrap();
1213
1214        // Create test content with angle bracket links containing parentheses
1215        let content = r#"
1216# Test Document with Paths Containing Parens
1217
1218[Upload Page](<app/(upload)/page.tsx>)
1219[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1220[Missing](<app/(missing)/file.md>)
1221"#;
1222
1223        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1224
1225        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226        let result = rule.check(&ctx).unwrap();
1227
1228        // Should only have one warning for the missing file
1229        assert_eq!(
1230            result.len(),
1231            1,
1232            "Should have exactly one warning for missing file. Got: {result:?}"
1233        );
1234        assert!(
1235            result[0].message.contains("app/(missing)/file.md"),
1236            "Warning should mention app/(missing)/file.md"
1237        );
1238    }
1239
1240    #[test]
1241    fn test_all_file_types_checked() {
1242        // Create a temporary directory for test files
1243        let temp_dir = tempdir().unwrap();
1244        let base_path = temp_dir.path();
1245
1246        // Create a test with various file types - all should be checked
1247        let content = r#"
1248[Image Link](image.jpg)
1249[Video Link](video.mp4)
1250[Markdown Link](document.md)
1251[PDF Link](file.pdf)
1252"#;
1253
1254        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1255
1256        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257        let result = rule.check(&ctx).unwrap();
1258
1259        // Should warn about all missing files regardless of extension
1260        assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1261    }
1262
1263    #[test]
1264    fn test_code_span_detection() {
1265        let rule = MD057ExistingRelativeLinks::new();
1266
1267        // Create a temporary directory for test files
1268        let temp_dir = tempdir().unwrap();
1269        let base_path = temp_dir.path();
1270
1271        let rule = rule.with_path(base_path);
1272
1273        // Test with document structure
1274        let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1275
1276        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277        let result = rule.check(&ctx).unwrap();
1278
1279        // Should only find the real link, not the one in code
1280        assert_eq!(result.len(), 1, "Should only flag the real link");
1281        assert!(result[0].message.contains("nonexistent.md"));
1282    }
1283
1284    #[test]
1285    fn test_inline_code_spans() {
1286        // Create a temporary directory for test files
1287        let temp_dir = tempdir().unwrap();
1288        let base_path = temp_dir.path();
1289
1290        // Create test content with links in inline code spans
1291        let content = r#"
1292# Test Document
1293
1294This is a normal link: [Link](missing.md)
1295
1296This is a code span with a link: `[Link](another-missing.md)`
1297
1298Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1299
1300    "#;
1301
1302        // Initialize rule with the base path
1303        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1304
1305        // Test the rule
1306        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307        let result = rule.check(&ctx).unwrap();
1308
1309        // Should only have warning for the normal link, not for links in code spans
1310        assert_eq!(result.len(), 1, "Should have exactly one warning");
1311        assert!(
1312            result[0].message.contains("missing.md"),
1313            "Warning should be for missing.md"
1314        );
1315        assert!(
1316            !result.iter().any(|w| w.message.contains("another-missing.md")),
1317            "Should not warn about link in code span"
1318        );
1319        assert!(
1320            !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1321            "Should not warn about link in inline code"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_extensionless_link_resolution() {
1327        // Create a temporary directory for test files
1328        let temp_dir = tempdir().unwrap();
1329        let base_path = temp_dir.path();
1330
1331        // Create a markdown file WITHOUT specifying .md extension in the link
1332        let page_path = base_path.join("page.md");
1333        File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1334
1335        // Test content with extensionless link that should resolve to page.md
1336        let content = r#"
1337# Test Document
1338
1339[Link without extension](page)
1340[Link with extension](page.md)
1341[Missing link](nonexistent)
1342"#;
1343
1344        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1345
1346        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347        let result = rule.check(&ctx).unwrap();
1348
1349        // Should only have warning for nonexistent link
1350        // Both "page" and "page.md" should resolve to the same file
1351        assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1352        assert!(
1353            result[0].message.contains("nonexistent"),
1354            "Warning should be for 'nonexistent' not 'page'"
1355        );
1356    }
1357
1358    // Cross-file validation tests
1359    #[test]
1360    fn test_cross_file_scope() {
1361        let rule = MD057ExistingRelativeLinks::new();
1362        assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1363    }
1364
1365    #[test]
1366    fn test_contribute_to_index_extracts_markdown_links() {
1367        let rule = MD057ExistingRelativeLinks::new();
1368        let content = r#"
1369# Document
1370
1371[Link to docs](./docs/guide.md)
1372[Link with fragment](./other.md#section)
1373[External link](https://example.com)
1374[Image link](image.png)
1375[Media file](video.mp4)
1376"#;
1377
1378        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379        let mut index = FileIndex::new();
1380        rule.contribute_to_index(&ctx, &mut index);
1381
1382        // Should only index markdown file links
1383        assert_eq!(index.cross_file_links.len(), 2);
1384
1385        // Check first link
1386        assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1387        assert_eq!(index.cross_file_links[0].fragment, "");
1388
1389        // Check second link (with fragment)
1390        assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1391        assert_eq!(index.cross_file_links[1].fragment, "section");
1392    }
1393
1394    #[test]
1395    fn test_contribute_to_index_skips_external_and_anchors() {
1396        let rule = MD057ExistingRelativeLinks::new();
1397        let content = r#"
1398# Document
1399
1400[External](https://example.com)
1401[Another external](http://example.org)
1402[Fragment only](#section)
1403[FTP link](ftp://files.example.com)
1404[Mail link](mailto:test@example.com)
1405[WWW link](www.example.com)
1406"#;
1407
1408        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409        let mut index = FileIndex::new();
1410        rule.contribute_to_index(&ctx, &mut index);
1411
1412        // Should not index any of these
1413        assert_eq!(index.cross_file_links.len(), 0);
1414    }
1415
1416    #[test]
1417    fn test_cross_file_check_valid_link() {
1418        use crate::workspace_index::WorkspaceIndex;
1419
1420        let rule = MD057ExistingRelativeLinks::new();
1421
1422        // Create a workspace index with the target file
1423        let mut workspace_index = WorkspaceIndex::new();
1424        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1425
1426        // Create file index with a link to an existing file
1427        let mut file_index = FileIndex::new();
1428        file_index.add_cross_file_link(CrossFileLinkIndex {
1429            target_path: "guide.md".to_string(),
1430            fragment: "".to_string(),
1431            line: 5,
1432            column: 1,
1433        });
1434
1435        // Run cross-file check from docs/index.md
1436        let warnings = rule
1437            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1438            .unwrap();
1439
1440        // Should have no warnings - file exists
1441        assert!(warnings.is_empty());
1442    }
1443
1444    #[test]
1445    fn test_cross_file_check_missing_link() {
1446        use crate::workspace_index::WorkspaceIndex;
1447
1448        let rule = MD057ExistingRelativeLinks::new();
1449
1450        // Create an empty workspace index
1451        let workspace_index = WorkspaceIndex::new();
1452
1453        // Create file index with a link to a missing file
1454        let mut file_index = FileIndex::new();
1455        file_index.add_cross_file_link(CrossFileLinkIndex {
1456            target_path: "missing.md".to_string(),
1457            fragment: "".to_string(),
1458            line: 5,
1459            column: 1,
1460        });
1461
1462        // Run cross-file check
1463        let warnings = rule
1464            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1465            .unwrap();
1466
1467        // Should have one warning for the missing file
1468        assert_eq!(warnings.len(), 1);
1469        assert!(warnings[0].message.contains("missing.md"));
1470        assert!(warnings[0].message.contains("does not exist"));
1471    }
1472
1473    #[test]
1474    fn test_cross_file_check_parent_path() {
1475        use crate::workspace_index::WorkspaceIndex;
1476
1477        let rule = MD057ExistingRelativeLinks::new();
1478
1479        // Create a workspace index with the target file at the root
1480        let mut workspace_index = WorkspaceIndex::new();
1481        workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1482
1483        // Create file index with a parent path link
1484        let mut file_index = FileIndex::new();
1485        file_index.add_cross_file_link(CrossFileLinkIndex {
1486            target_path: "../readme.md".to_string(),
1487            fragment: "".to_string(),
1488            line: 5,
1489            column: 1,
1490        });
1491
1492        // Run cross-file check from docs/guide.md
1493        let warnings = rule
1494            .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1495            .unwrap();
1496
1497        // Should have no warnings - file exists at normalized path
1498        assert!(warnings.is_empty());
1499    }
1500
1501    #[test]
1502    fn test_cross_file_check_html_link_with_md_source() {
1503        // Test that .html links are accepted when corresponding .md source exists
1504        // This supports mdBook and similar doc generators that compile .md to .html
1505        use crate::workspace_index::WorkspaceIndex;
1506
1507        let rule = MD057ExistingRelativeLinks::new();
1508
1509        // Create a workspace index with the .md source file
1510        let mut workspace_index = WorkspaceIndex::new();
1511        workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1512
1513        // Create file index with an .html link (from another rule like MD051)
1514        let mut file_index = FileIndex::new();
1515        file_index.add_cross_file_link(CrossFileLinkIndex {
1516            target_path: "guide.html".to_string(),
1517            fragment: "section".to_string(),
1518            line: 10,
1519            column: 5,
1520        });
1521
1522        // Run cross-file check from docs/index.md
1523        let warnings = rule
1524            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1525            .unwrap();
1526
1527        // Should have no warnings - .md source exists for the .html link
1528        assert!(
1529            warnings.is_empty(),
1530            "Expected no warnings for .html link with .md source, got: {warnings:?}"
1531        );
1532    }
1533
1534    #[test]
1535    fn test_cross_file_check_html_link_without_source() {
1536        // Test that .html links without corresponding .md source ARE flagged
1537        use crate::workspace_index::WorkspaceIndex;
1538
1539        let rule = MD057ExistingRelativeLinks::new();
1540
1541        // Create an empty workspace index
1542        let workspace_index = WorkspaceIndex::new();
1543
1544        // Create file index with an .html link to a non-existent file
1545        let mut file_index = FileIndex::new();
1546        file_index.add_cross_file_link(CrossFileLinkIndex {
1547            target_path: "missing.html".to_string(),
1548            fragment: "".to_string(),
1549            line: 10,
1550            column: 5,
1551        });
1552
1553        // Run cross-file check from docs/index.md
1554        let warnings = rule
1555            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1556            .unwrap();
1557
1558        // Should have one warning - no .md source exists
1559        assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1560        assert!(warnings[0].message.contains("missing.html"));
1561    }
1562
1563    #[test]
1564    fn test_normalize_path_function() {
1565        // Test simple cases
1566        assert_eq!(
1567            normalize_path(Path::new("docs/guide.md")),
1568            PathBuf::from("docs/guide.md")
1569        );
1570
1571        // Test current directory removal
1572        assert_eq!(
1573            normalize_path(Path::new("./docs/guide.md")),
1574            PathBuf::from("docs/guide.md")
1575        );
1576
1577        // Test parent directory resolution
1578        assert_eq!(
1579            normalize_path(Path::new("docs/sub/../guide.md")),
1580            PathBuf::from("docs/guide.md")
1581        );
1582
1583        // Test multiple parent directories
1584        assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1585    }
1586
1587    #[test]
1588    fn test_html_link_with_md_source() {
1589        // Links to .html files should pass if corresponding .md source exists
1590        let temp_dir = tempdir().unwrap();
1591        let base_path = temp_dir.path();
1592
1593        // Create guide.md (source file)
1594        let md_file = base_path.join("guide.md");
1595        File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1596
1597        let content = r#"
1598[Read the guide](guide.html)
1599[Also here](getting-started.html)
1600"#;
1601
1602        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1603        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604        let result = rule.check(&ctx).unwrap();
1605
1606        // guide.html passes (guide.md exists), getting-started.html fails
1607        assert_eq!(
1608            result.len(),
1609            1,
1610            "Should only warn about missing source. Got: {result:?}"
1611        );
1612        assert!(result[0].message.contains("getting-started.html"));
1613    }
1614
1615    #[test]
1616    fn test_htm_link_with_md_source() {
1617        // .htm extension should also check for markdown source
1618        let temp_dir = tempdir().unwrap();
1619        let base_path = temp_dir.path();
1620
1621        let md_file = base_path.join("page.md");
1622        File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1623
1624        let content = "[Page](page.htm)";
1625
1626        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1627        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628        let result = rule.check(&ctx).unwrap();
1629
1630        assert!(
1631            result.is_empty(),
1632            "Should not warn when .md source exists for .htm link"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_html_link_finds_various_markdown_extensions() {
1638        // Should find .mdx, .markdown, etc. as source files
1639        let temp_dir = tempdir().unwrap();
1640        let base_path = temp_dir.path();
1641
1642        File::create(base_path.join("doc.md")).unwrap();
1643        File::create(base_path.join("tutorial.mdx")).unwrap();
1644        File::create(base_path.join("guide.markdown")).unwrap();
1645
1646        let content = r#"
1647[Doc](doc.html)
1648[Tutorial](tutorial.html)
1649[Guide](guide.html)
1650"#;
1651
1652        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1653        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654        let result = rule.check(&ctx).unwrap();
1655
1656        assert!(
1657            result.is_empty(),
1658            "Should find all markdown variants as source files. Got: {result:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_html_link_in_subdirectory() {
1664        // Should find markdown source in subdirectories
1665        let temp_dir = tempdir().unwrap();
1666        let base_path = temp_dir.path();
1667
1668        let docs_dir = base_path.join("docs");
1669        std::fs::create_dir(&docs_dir).unwrap();
1670        File::create(docs_dir.join("guide.md"))
1671            .unwrap()
1672            .write_all(b"# Guide")
1673            .unwrap();
1674
1675        let content = "[Guide](docs/guide.html)";
1676
1677        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1678        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679        let result = rule.check(&ctx).unwrap();
1680
1681        assert!(result.is_empty(), "Should find markdown source in subdirectory");
1682    }
1683
1684    #[test]
1685    fn test_absolute_path_skipped_in_check() {
1686        // Test that absolute paths are skipped during link validation
1687        // This fixes the bug where /pkg/runtime was being flagged
1688        let temp_dir = tempdir().unwrap();
1689        let base_path = temp_dir.path();
1690
1691        let content = r#"
1692# Test Document
1693
1694[Go Runtime](/pkg/runtime)
1695[Go Runtime with Fragment](/pkg/runtime#section)
1696[API Docs](/api/v1/users)
1697[Blog Post](/blog/2024/release.html)
1698[React Hook](/react/hooks/use-state.html)
1699"#;
1700
1701        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1702        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703        let result = rule.check(&ctx).unwrap();
1704
1705        // Should have NO warnings - all absolute paths should be skipped
1706        assert!(
1707            result.is_empty(),
1708            "Absolute paths should be skipped. Got warnings: {result:?}"
1709        );
1710    }
1711
1712    #[test]
1713    fn test_absolute_path_skipped_in_cross_file_check() {
1714        // Test that absolute paths are skipped in cross_file_check()
1715        use crate::workspace_index::WorkspaceIndex;
1716
1717        let rule = MD057ExistingRelativeLinks::new();
1718
1719        // Create an empty workspace index (no files exist)
1720        let workspace_index = WorkspaceIndex::new();
1721
1722        // Create file index with absolute path links (should be skipped)
1723        let mut file_index = FileIndex::new();
1724        file_index.add_cross_file_link(CrossFileLinkIndex {
1725            target_path: "/pkg/runtime.md".to_string(),
1726            fragment: "".to_string(),
1727            line: 5,
1728            column: 1,
1729        });
1730        file_index.add_cross_file_link(CrossFileLinkIndex {
1731            target_path: "/api/v1/users.md".to_string(),
1732            fragment: "section".to_string(),
1733            line: 10,
1734            column: 1,
1735        });
1736
1737        // Run cross-file check
1738        let warnings = rule
1739            .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1740            .unwrap();
1741
1742        // Should have NO warnings - absolute paths should be skipped
1743        assert!(
1744            warnings.is_empty(),
1745            "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_protocol_relative_url_not_skipped() {
1751        // Test that protocol-relative URLs (//example.com) are NOT skipped as absolute paths
1752        // They should still be caught by is_external_url() though
1753        let temp_dir = tempdir().unwrap();
1754        let base_path = temp_dir.path();
1755
1756        let content = r#"
1757# Test Document
1758
1759[External](//example.com/page)
1760[Another](//cdn.example.com/asset.js)
1761"#;
1762
1763        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1764        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765        let result = rule.check(&ctx).unwrap();
1766
1767        // Should have NO warnings - protocol-relative URLs are external and should be skipped
1768        assert!(
1769            result.is_empty(),
1770            "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
1771        );
1772    }
1773
1774    #[test]
1775    fn test_email_addresses_skipped() {
1776        // Test that email addresses without mailto: are skipped
1777        // These are clearly not file links (the @ symbol is definitive)
1778        let temp_dir = tempdir().unwrap();
1779        let base_path = temp_dir.path();
1780
1781        let content = r#"
1782# Test Document
1783
1784[Contact](user@example.com)
1785[Steering](steering@kubernetes.io)
1786[Support](john.doe+filter@company.co.uk)
1787[User](user_name@sub.domain.com)
1788"#;
1789
1790        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1791        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1792        let result = rule.check(&ctx).unwrap();
1793
1794        // Should have NO warnings - email addresses are clearly not file links and should be skipped
1795        assert!(
1796            result.is_empty(),
1797            "Email addresses should be skipped. Got warnings: {result:?}"
1798        );
1799    }
1800
1801    #[test]
1802    fn test_email_addresses_vs_file_paths() {
1803        // Test that email addresses (anything with @) are skipped
1804        // Note: File paths with @ are extremely rare, so we treat anything with @ as an email
1805        let temp_dir = tempdir().unwrap();
1806        let base_path = temp_dir.path();
1807
1808        let content = r#"
1809# Test Document
1810
1811[Email](user@example.com)  <!-- Should be skipped (email) -->
1812[Email2](steering@kubernetes.io)  <!-- Should be skipped (email) -->
1813[Email3](user@file.md)  <!-- Should be skipped (has @, treated as email) -->
1814"#;
1815
1816        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1817        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818        let result = rule.check(&ctx).unwrap();
1819
1820        // All should be skipped - anything with @ is treated as an email
1821        assert!(
1822            result.is_empty(),
1823            "All email addresses should be skipped. Got: {result:?}"
1824        );
1825    }
1826
1827    #[test]
1828    fn test_diagnostic_position_accuracy() {
1829        // Test that diagnostics point to the URL, not the link text
1830        let temp_dir = tempdir().unwrap();
1831        let base_path = temp_dir.path();
1832
1833        // Position markers:     0         1         2         3
1834        //                       0123456789012345678901234567890123456789
1835        let content = "prefix [text](missing.md) suffix";
1836        //             The URL "missing.md" starts at 0-indexed position 14
1837        //             which is 1-indexed column 15, and ends at 0-indexed 24 (1-indexed column 25)
1838
1839        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1840        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841        let result = rule.check(&ctx).unwrap();
1842
1843        assert_eq!(result.len(), 1, "Should have exactly one warning");
1844        assert_eq!(result[0].line, 1, "Should be on line 1");
1845        assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
1846        assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
1847    }
1848
1849    #[test]
1850    fn test_diagnostic_position_angle_brackets() {
1851        // Test position accuracy with angle bracket links
1852        let temp_dir = tempdir().unwrap();
1853        let base_path = temp_dir.path();
1854
1855        // Position markers:     0         1         2
1856        //                       012345678901234567890
1857        let content = "[link](<missing.md>)";
1858        //             The URL "missing.md" starts at 0-indexed position 8 (1-indexed column 9)
1859
1860        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1861        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862        let result = rule.check(&ctx).unwrap();
1863
1864        assert_eq!(result.len(), 1, "Should have exactly one warning");
1865        assert_eq!(result[0].line, 1, "Should be on line 1");
1866        assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
1867    }
1868
1869    #[test]
1870    fn test_diagnostic_position_multiline() {
1871        // Test that line numbers are correct for links on different lines
1872        let temp_dir = tempdir().unwrap();
1873        let base_path = temp_dir.path();
1874
1875        let content = r#"# Title
1876Some text on line 2
1877[link on line 3](missing1.md)
1878More text
1879[link on line 5](missing2.md)"#;
1880
1881        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1882        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883        let result = rule.check(&ctx).unwrap();
1884
1885        assert_eq!(result.len(), 2, "Should have two warnings");
1886
1887        // First warning should be on line 3
1888        assert_eq!(result[0].line, 3, "First warning should be on line 3");
1889        assert!(result[0].message.contains("missing1.md"));
1890
1891        // Second warning should be on line 5
1892        assert_eq!(result[1].line, 5, "Second warning should be on line 5");
1893        assert!(result[1].message.contains("missing2.md"));
1894    }
1895
1896    #[test]
1897    fn test_diagnostic_position_with_spaces() {
1898        // Test position with URLs that have spaces in parentheses
1899        let temp_dir = tempdir().unwrap();
1900        let base_path = temp_dir.path();
1901
1902        let content = "[link]( missing.md )";
1903        //             0123456789012345678901
1904        //             0-indexed position 8 is 'm' in 'missing.md' (after space and paren)
1905        //             which is 1-indexed column 9
1906
1907        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1908        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909        let result = rule.check(&ctx).unwrap();
1910
1911        assert_eq!(result.len(), 1, "Should have exactly one warning");
1912        // The regex captures the URL without leading/trailing spaces
1913        assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
1914    }
1915
1916    #[test]
1917    fn test_diagnostic_position_image() {
1918        // Test that image diagnostics also have correct positions
1919        let temp_dir = tempdir().unwrap();
1920        let base_path = temp_dir.path();
1921
1922        let content = "![alt text](missing.jpg)";
1923
1924        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1925        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926        let result = rule.check(&ctx).unwrap();
1927
1928        assert_eq!(result.len(), 1, "Should have exactly one warning for image");
1929        assert_eq!(result[0].line, 1);
1930        // Images use start_col from the parser, which should point to the URL
1931        assert!(result[0].column > 0, "Should have valid column position");
1932        assert!(result[0].message.contains("missing.jpg"));
1933    }
1934
1935    #[test]
1936    fn test_wikilinks_skipped() {
1937        // Wikilinks should not trigger MD057 warnings
1938        // They use a different linking system (e.g., Obsidian, wiki software)
1939        let temp_dir = tempdir().unwrap();
1940        let base_path = temp_dir.path();
1941
1942        let content = r#"# Test Document
1943
1944[[Microsoft#Windows OS]]
1945[[SomePage]]
1946[[Page With Spaces]]
1947[[path/to/page#section]]
1948[[page|Display Text]]
1949
1950This is a [real missing link](missing.md) that should be flagged.
1951"#;
1952
1953        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1954        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1955        let result = rule.check(&ctx).unwrap();
1956
1957        // Should only warn about the regular markdown link, not wikilinks
1958        assert_eq!(
1959            result.len(),
1960            1,
1961            "Should only warn about missing.md, not wikilinks. Got: {result:?}"
1962        );
1963        assert!(
1964            result[0].message.contains("missing.md"),
1965            "Warning should be for missing.md, not wikilinks"
1966        );
1967    }
1968
1969    #[test]
1970    fn test_wikilinks_not_added_to_index() {
1971        // Wikilinks should not be added to the cross-file link index
1972        let temp_dir = tempdir().unwrap();
1973        let base_path = temp_dir.path();
1974
1975        let content = r#"# Test Document
1976
1977[[Microsoft#Windows OS]]
1978[[SomePage#section]]
1979[Regular Link](other.md)
1980"#;
1981
1982        let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1983        let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1984
1985        let mut file_index = FileIndex::new();
1986        rule.contribute_to_index(&ctx, &mut file_index);
1987
1988        // Should only have the regular markdown link (if it's a markdown file)
1989        // Wikilinks should not be added
1990        let cross_file_links = &file_index.cross_file_links;
1991        assert_eq!(
1992            cross_file_links.len(),
1993            1,
1994            "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1995        );
1996        assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1997    }
1998}