1use 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
19static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
21 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
22
23fn reset_file_existence_cache() {
25 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
26 cache.clear();
27 }
28}
29
30fn 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(), }
36}
37
38fn file_exists_or_markdown_extension(path: &Path) -> bool {
41 if file_exists_with_cache(path) {
43 return true;
44 }
45
46 if path.extension().is_none() {
48 for ext in MARKDOWN_EXTENSIONS {
49 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
60static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
62
63static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
67 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
68
69static URL_EXTRACT_REGEX: LazyLock<Regex> =
72 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
73
74static 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
80static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
82
83#[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
95const MARKDOWN_EXTENSIONS: &[&str] = &[
97 ".md",
98 ".markdown",
99 ".mdx",
100 ".mkd",
101 ".mkdn",
102 ".mdown",
103 ".mdwn",
104 ".qmd",
105 ".rmd",
106];
107
108#[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#[derive(Debug, Default, Clone)]
117pub struct MD057ExistingRelativeLinks {
118 base_path: Arc<Mutex<Option<PathBuf>>>,
120}
121
122impl MD057ExistingRelativeLinks {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 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 pub fn from_config_struct(_config: MD057Config) -> Self {
144 Self::default()
145 }
146
147 #[inline]
158 fn is_external_url(&self, url: &str) -> bool {
159 if url.is_empty() {
160 return false;
161 }
162
163 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
165 return true;
166 }
167
168 if url.starts_with("{{") || url.starts_with("{%") {
171 return true;
172 }
173
174 if url.ends_with(".com") {
181 return true;
182 }
183
184 if url.starts_with('/') {
188 return true;
189 }
190
191 if url.starts_with('~') || url.starts_with('@') {
195 return true;
196 }
197
198 false
200 }
201
202 #[inline]
204 fn is_fragment_only_link(&self, url: &str) -> bool {
205 url.starts_with('#')
206 }
207
208 fn url_decode(path: &str) -> String {
212 if !path.contains('%') {
214 return path.to_string();
215 }
216
217 let bytes = path.as_bytes();
218 let mut result = Vec::with_capacity(bytes.len());
219 let mut i = 0;
220
221 while i < bytes.len() {
222 if bytes[i] == b'%' && i + 2 < bytes.len() {
223 let hex1 = bytes[i + 1];
225 let hex2 = bytes[i + 2];
226 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
227 result.push(d1 * 16 + d2);
228 i += 3;
229 continue;
230 }
231 }
232 result.push(bytes[i]);
233 i += 1;
234 }
235
236 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
238 }
239
240 fn strip_query_and_fragment(url: &str) -> &str {
248 let query_pos = url.find('?');
251 let fragment_pos = url.find('#');
252
253 match (query_pos, fragment_pos) {
254 (Some(q), Some(f)) => {
255 &url[..q.min(f)]
257 }
258 (Some(q), None) => &url[..q],
259 (None, Some(f)) => &url[..f],
260 (None, None) => url,
261 }
262 }
263
264 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
266 base_path.join(link)
267 }
268
269 fn process_link_with_base(
271 &self,
272 url: &str,
273 line_num: usize,
274 column: usize,
275 base_path: &Path,
276 warnings: &mut Vec<LintWarning>,
277 ) {
278 if url.is_empty() {
280 return;
281 }
282
283 if self.is_external_url(url) || self.is_fragment_only_link(url) {
285 return;
286 }
287
288 let file_path = Self::strip_query_and_fragment(url);
291
292 let decoded_path = Self::url_decode(file_path);
295
296 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
298 if !file_exists_or_markdown_extension(&resolved_path) {
300 warnings.push(LintWarning {
301 rule_name: Some(self.name().to_string()),
302 line: line_num,
303 column,
304 end_line: line_num,
305 end_column: column + url.len(),
306 message: format!("Relative link '{url}' does not exist"),
307 severity: Severity::Warning,
308 fix: None, });
310 }
311 }
312}
313
314impl Rule for MD057ExistingRelativeLinks {
315 fn name(&self) -> &'static str {
316 "MD057"
317 }
318
319 fn description(&self) -> &'static str {
320 "Relative links should point to existing files"
321 }
322
323 fn category(&self) -> RuleCategory {
324 RuleCategory::Link
325 }
326
327 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
328 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
329 }
330
331 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
332 let content = ctx.content;
333
334 if content.is_empty() || !content.contains('[') {
336 return Ok(Vec::new());
337 }
338
339 if !content.contains("](") {
341 return Ok(Vec::new());
342 }
343
344 reset_file_existence_cache();
346
347 let mut warnings = Vec::new();
348
349 let base_path: Option<PathBuf> = {
353 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
355 if explicit_base.is_some() {
356 explicit_base
357 } else if let Some(ref source_file) = ctx.source_file {
358 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
362 resolved_file
363 .parent()
364 .map(|p| p.to_path_buf())
365 .or_else(|| Some(CURRENT_DIR.clone()))
366 } else {
367 None
369 }
370 };
371
372 let Some(base_path) = base_path else {
374 return Ok(warnings);
375 };
376
377 if !ctx.links.is_empty() {
379 let line_index = &ctx.line_index;
381
382 let element_cache = ElementCache::new(content);
384
385 let lines: Vec<&str> = content.lines().collect();
387
388 for link in &ctx.links {
389 let line_idx = link.line - 1;
390 if line_idx >= lines.len() {
391 continue;
392 }
393
394 let line = lines[line_idx];
395
396 if !line.contains("](") {
398 continue;
399 }
400
401 for link_match in LINK_START_REGEX.find_iter(line) {
403 let start_pos = link_match.start();
404 let end_pos = link_match.end();
405
406 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
408 let absolute_start_pos = line_start_byte + start_pos;
409
410 if element_cache.is_in_code_span(absolute_start_pos) {
412 continue;
413 }
414
415 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
419 .captures_at(line, end_pos - 1)
420 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
421 .or_else(|| {
422 URL_EXTRACT_REGEX
423 .captures_at(line, end_pos - 1)
424 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
425 });
426
427 if let Some((_caps, url_group)) = caps_and_url {
428 let url = url_group.as_str().trim();
429
430 let column = start_pos + 1;
432
433 self.process_link_with_base(url, link.line, column, &base_path, &mut warnings);
435 }
436 }
437 }
438 }
439
440 for image in &ctx.images {
442 let url = image.url.as_ref();
443 self.process_link_with_base(url, image.line, image.start_col + 1, &base_path, &mut warnings);
444 }
445
446 Ok(warnings)
447 }
448
449 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
450 Ok(ctx.content.to_string())
451 }
452
453 fn as_any(&self) -> &dyn std::any::Any {
454 self
455 }
456
457 fn default_config_section(&self) -> Option<(String, toml::Value)> {
458 None
460 }
461
462 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
463 where
464 Self: Sized,
465 {
466 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
467 Box::new(Self::from_config_struct(rule_config))
468 }
469
470 fn cross_file_scope(&self) -> CrossFileScope {
471 CrossFileScope::Workspace
472 }
473
474 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
475 let content = ctx.content;
476
477 if content.is_empty() || !content.contains("](") {
479 return;
480 }
481
482 let lines: Vec<&str> = content.lines().collect();
484 let element_cache = ElementCache::new(content);
485 let line_index = &ctx.line_index;
486
487 for link in &ctx.links {
488 let line_idx = link.line - 1;
489 if line_idx >= lines.len() {
490 continue;
491 }
492
493 let line = lines[line_idx];
494 if !line.contains("](") {
495 continue;
496 }
497
498 for link_match in LINK_START_REGEX.find_iter(line) {
500 let start_pos = link_match.start();
501 let end_pos = link_match.end();
502
503 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
505 let absolute_start_pos = line_start_byte + start_pos;
506
507 if element_cache.is_in_code_span(absolute_start_pos) {
509 continue;
510 }
511
512 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
516 .captures_at(line, end_pos - 1)
517 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
518
519 if let Some(caps) = caps_result
520 && let Some(url_group) = caps.get(1)
521 {
522 let file_path = url_group.as_str().trim();
523
524 if file_path.is_empty()
527 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
528 || file_path.starts_with("www.")
529 || file_path.starts_with('#')
530 || file_path.starts_with("{{")
531 || file_path.starts_with("{%")
532 || file_path.starts_with('/')
533 || file_path.starts_with('~')
534 || file_path.starts_with('@')
535 {
536 continue;
537 }
538
539 let file_path = Self::strip_query_and_fragment(file_path);
541
542 let fragment = caps.get(2).map(|m| m.as_str().trim_start_matches('#')).unwrap_or("");
544
545 if is_markdown_file(file_path) {
548 index.add_cross_file_link(CrossFileLinkIndex {
549 target_path: file_path.to_string(),
550 fragment: fragment.to_string(),
551 line: link.line,
552 column: start_pos + 1,
553 });
554 }
555 }
556 }
557 }
558 }
559
560 fn cross_file_check(
561 &self,
562 file_path: &Path,
563 file_index: &FileIndex,
564 workspace_index: &crate::workspace_index::WorkspaceIndex,
565 ) -> LintResult {
566 let mut warnings = Vec::new();
567
568 let file_dir = file_path.parent();
570
571 for cross_link in &file_index.cross_file_links {
572 let target_path = if cross_link.target_path.starts_with('/') {
574 let stripped = cross_link.target_path.trim_start_matches('/');
577 resolve_absolute_link(file_path, stripped)
578 } else if let Some(dir) = file_dir {
579 dir.join(&cross_link.target_path)
580 } else {
581 Path::new(&cross_link.target_path).to_path_buf()
582 };
583
584 let target_path = normalize_path(&target_path);
586
587 if !workspace_index.contains_file(&target_path) {
589 if !target_path.exists() {
591 warnings.push(LintWarning {
592 rule_name: Some(self.name().to_string()),
593 line: cross_link.line,
594 column: cross_link.column,
595 end_line: cross_link.line,
596 end_column: cross_link.column + cross_link.target_path.len(),
597 message: format!("Relative link '{}' does not exist", cross_link.target_path),
598 severity: Severity::Warning,
599 fix: None,
600 });
601 }
602 }
603 }
604
605 Ok(warnings)
606 }
607}
608
609fn normalize_path(path: &Path) -> PathBuf {
611 let mut components = Vec::new();
612
613 for component in path.components() {
614 match component {
615 std::path::Component::ParentDir => {
616 if !components.is_empty() {
618 components.pop();
619 }
620 }
621 std::path::Component::CurDir => {
622 }
624 _ => {
625 components.push(component);
626 }
627 }
628 }
629
630 components.iter().collect()
631}
632
633fn resolve_absolute_link(file_path: &Path, stripped_path: &str) -> PathBuf {
639 let mut current = file_path.parent();
641 while let Some(dir) = current {
642 let candidate = dir.join(stripped_path);
643 if candidate.exists() {
644 return candidate;
645 }
646 current = dir.parent();
647 }
648
649 file_path
652 .parent()
653 .map(|d| d.join(stripped_path))
654 .unwrap_or_else(|| PathBuf::from(stripped_path))
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660 use std::fs::File;
661 use std::io::Write;
662 use tempfile::tempdir;
663
664 #[test]
665 fn test_strip_query_and_fragment() {
666 assert_eq!(
668 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
669 "file.png"
670 );
671 assert_eq!(
672 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
673 "file.png"
674 );
675 assert_eq!(
676 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
677 "file.png"
678 );
679
680 assert_eq!(
682 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
683 "file.md"
684 );
685 assert_eq!(
686 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
687 "file.md"
688 );
689
690 assert_eq!(
692 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
693 "file.md"
694 );
695
696 assert_eq!(
698 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
699 "file.png"
700 );
701
702 assert_eq!(
704 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
705 "path/to/image.png"
706 );
707 assert_eq!(
708 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
709 "path/to/image.png"
710 );
711
712 assert_eq!(
714 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
715 "file.md"
716 );
717 }
718
719 #[test]
720 fn test_url_decode() {
721 assert_eq!(
723 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
724 "penguin with space.jpg"
725 );
726
727 assert_eq!(
729 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
730 "assets/my file name.png"
731 );
732
733 assert_eq!(
735 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
736 "hello world!.md"
737 );
738
739 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
741
742 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
744
745 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
747
748 assert_eq!(
750 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
751 "normal-file.md"
752 );
753
754 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
756
757 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
759
760 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
762
763 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
765
766 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
768
769 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
771
772 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
774
775 assert_eq!(
777 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
778 "path/to/file.md"
779 );
780
781 assert_eq!(
783 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
784 "hello world/foo bar.md"
785 );
786
787 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
789
790 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
792 }
793
794 #[test]
795 fn test_url_encoded_filenames() {
796 let temp_dir = tempdir().unwrap();
798 let base_path = temp_dir.path();
799
800 let file_with_spaces = base_path.join("penguin with space.jpg");
802 File::create(&file_with_spaces)
803 .unwrap()
804 .write_all(b"image data")
805 .unwrap();
806
807 let subdir = base_path.join("my images");
809 std::fs::create_dir(&subdir).unwrap();
810 let nested_file = subdir.join("photo 1.png");
811 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
812
813 let content = r#"
815# Test Document with URL-Encoded Links
816
817
818
819
820"#;
821
822 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
823
824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
825 let result = rule.check(&ctx).unwrap();
826
827 assert_eq!(
829 result.len(),
830 1,
831 "Should only warn about missing%20file.jpg. Got: {result:?}"
832 );
833 assert!(
834 result[0].message.contains("missing%20file.jpg"),
835 "Warning should mention the URL-encoded filename"
836 );
837 }
838
839 #[test]
840 fn test_external_urls() {
841 let rule = MD057ExistingRelativeLinks::new();
842
843 assert!(rule.is_external_url("https://example.com"));
845 assert!(rule.is_external_url("http://example.com"));
846 assert!(rule.is_external_url("ftp://example.com"));
847 assert!(rule.is_external_url("www.example.com"));
848 assert!(rule.is_external_url("example.com"));
849
850 assert!(rule.is_external_url("file:///path/to/file"));
852 assert!(rule.is_external_url("smb://server/share"));
853 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
854 assert!(rule.is_external_url("mailto:user@example.com"));
855 assert!(rule.is_external_url("tel:+1234567890"));
856 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
857 assert!(rule.is_external_url("javascript:void(0)"));
858 assert!(rule.is_external_url("ssh://git@github.com/repo"));
859 assert!(rule.is_external_url("git://github.com/repo.git"));
860
861 assert!(rule.is_external_url("{{URL}}")); assert!(rule.is_external_url("{{#URL}}")); assert!(rule.is_external_url("{{> partial}}")); assert!(rule.is_external_url("{{ variable }}")); assert!(rule.is_external_url("{{% include %}}")); assert!(rule.is_external_url("{{")); assert!(rule.is_external_url("/api/v1/users"));
872 assert!(rule.is_external_url("/blog/2024/release.html"));
873 assert!(rule.is_external_url("/react/hooks/use-state.html"));
874 assert!(rule.is_external_url("/pkg/runtime"));
875 assert!(rule.is_external_url("/doc/go1compat"));
876 assert!(rule.is_external_url("/index.html"));
877 assert!(rule.is_external_url("/assets/logo.png"));
878
879 assert!(rule.is_external_url("~/assets/image.png"));
882 assert!(rule.is_external_url("~/components/Button.vue"));
883 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
887 assert!(rule.is_external_url("@images/photo.jpg"));
888 assert!(rule.is_external_url("@assets/styles.css"));
889
890 assert!(!rule.is_external_url("./relative/path.md"));
892 assert!(!rule.is_external_url("relative/path.md"));
893 assert!(!rule.is_external_url("../parent/path.md"));
894 }
895
896 #[test]
897 fn test_framework_path_aliases() {
898 let temp_dir = tempdir().unwrap();
900 let base_path = temp_dir.path();
901
902 let content = r#"
904# Framework Path Aliases
905
906
907
908
909
910[Link](@/pages/about.md)
911
912This is a [real missing link](missing.md) that should be flagged.
913"#;
914
915 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
916
917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
918 let result = rule.check(&ctx).unwrap();
919
920 assert_eq!(
922 result.len(),
923 1,
924 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
925 );
926 assert!(
927 result[0].message.contains("missing.md"),
928 "Warning should be for missing.md"
929 );
930 }
931
932 #[test]
933 fn test_url_decode_security_path_traversal() {
934 let temp_dir = tempdir().unwrap();
937 let base_path = temp_dir.path();
938
939 let file_in_base = base_path.join("safe.md");
941 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
942
943 let content = r#"
948[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
949[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
950[Safe link](safe.md)
951"#;
952
953 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
954
955 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
956 let result = rule.check(&ctx).unwrap();
957
958 assert_eq!(
961 result.len(),
962 2,
963 "Should have warnings for traversal attempts. Got: {result:?}"
964 );
965 }
966
967 #[test]
968 fn test_url_encoded_utf8_filenames() {
969 let temp_dir = tempdir().unwrap();
971 let base_path = temp_dir.path();
972
973 let cafe_file = base_path.join("café.md");
975 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
976
977 let content = r#"
978[Café link](caf%C3%A9.md)
979[Missing unicode](r%C3%A9sum%C3%A9.md)
980"#;
981
982 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
983
984 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
985 let result = rule.check(&ctx).unwrap();
986
987 assert_eq!(
989 result.len(),
990 1,
991 "Should only warn about missing résumé.md. Got: {result:?}"
992 );
993 assert!(
994 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
995 "Warning should mention the URL-encoded filename"
996 );
997 }
998
999 #[test]
1000 fn test_no_warnings_without_base_path() {
1001 let rule = MD057ExistingRelativeLinks::new();
1002 let content = "[Link](missing.md)";
1003
1004 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1005 let result = rule.check(&ctx).unwrap();
1006 assert!(result.is_empty(), "Should have no warnings without base path");
1007 }
1008
1009 #[test]
1010 fn test_existing_and_missing_links() {
1011 let temp_dir = tempdir().unwrap();
1013 let base_path = temp_dir.path();
1014
1015 let exists_path = base_path.join("exists.md");
1017 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1018
1019 assert!(exists_path.exists(), "exists.md should exist for this test");
1021
1022 let content = r#"
1024# Test Document
1025
1026[Valid Link](exists.md)
1027[Invalid Link](missing.md)
1028[External Link](https://example.com)
1029[Media Link](image.jpg)
1030 "#;
1031
1032 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1034
1035 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1037 let result = rule.check(&ctx).unwrap();
1038
1039 assert_eq!(result.len(), 2);
1041 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1042 assert!(messages.iter().any(|m| m.contains("missing.md")));
1043 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1044 }
1045
1046 #[test]
1047 fn test_angle_bracket_links() {
1048 let temp_dir = tempdir().unwrap();
1050 let base_path = temp_dir.path();
1051
1052 let exists_path = base_path.join("exists.md");
1054 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1055
1056 let content = r#"
1058# Test Document
1059
1060[Valid Link](<exists.md>)
1061[Invalid Link](<missing.md>)
1062[External Link](<https://example.com>)
1063 "#;
1064
1065 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1067
1068 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1069 let result = rule.check(&ctx).unwrap();
1070
1071 assert_eq!(result.len(), 1, "Should have exactly one warning");
1073 assert!(
1074 result[0].message.contains("missing.md"),
1075 "Warning should mention missing.md"
1076 );
1077 }
1078
1079 #[test]
1080 fn test_angle_bracket_links_with_parens() {
1081 let temp_dir = tempdir().unwrap();
1083 let base_path = temp_dir.path();
1084
1085 let app_dir = base_path.join("app");
1087 std::fs::create_dir(&app_dir).unwrap();
1088 let upload_dir = app_dir.join("(upload)");
1089 std::fs::create_dir(&upload_dir).unwrap();
1090 let page_file = upload_dir.join("page.tsx");
1091 File::create(&page_file)
1092 .unwrap()
1093 .write_all(b"export default function Page() {}")
1094 .unwrap();
1095
1096 let content = r#"
1098# Test Document with Paths Containing Parens
1099
1100[Upload Page](<app/(upload)/page.tsx>)
1101[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1102[Missing](<app/(missing)/file.md>)
1103"#;
1104
1105 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1106
1107 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1108 let result = rule.check(&ctx).unwrap();
1109
1110 assert_eq!(
1112 result.len(),
1113 1,
1114 "Should have exactly one warning for missing file. Got: {result:?}"
1115 );
1116 assert!(
1117 result[0].message.contains("app/(missing)/file.md"),
1118 "Warning should mention app/(missing)/file.md"
1119 );
1120 }
1121
1122 #[test]
1123 fn test_all_file_types_checked() {
1124 let temp_dir = tempdir().unwrap();
1126 let base_path = temp_dir.path();
1127
1128 let content = r#"
1130[Image Link](image.jpg)
1131[Video Link](video.mp4)
1132[Markdown Link](document.md)
1133[PDF Link](file.pdf)
1134"#;
1135
1136 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1137
1138 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1139 let result = rule.check(&ctx).unwrap();
1140
1141 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1143 }
1144
1145 #[test]
1146 fn test_code_span_detection() {
1147 let rule = MD057ExistingRelativeLinks::new();
1148
1149 let temp_dir = tempdir().unwrap();
1151 let base_path = temp_dir.path();
1152
1153 let rule = rule.with_path(base_path);
1154
1155 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1157
1158 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert_eq!(result.len(), 1, "Should only flag the real link");
1163 assert!(result[0].message.contains("nonexistent.md"));
1164 }
1165
1166 #[test]
1167 fn test_inline_code_spans() {
1168 let temp_dir = tempdir().unwrap();
1170 let base_path = temp_dir.path();
1171
1172 let content = r#"
1174# Test Document
1175
1176This is a normal link: [Link](missing.md)
1177
1178This is a code span with a link: `[Link](another-missing.md)`
1179
1180Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1181
1182 "#;
1183
1184 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1186
1187 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1189 let result = rule.check(&ctx).unwrap();
1190
1191 assert_eq!(result.len(), 1, "Should have exactly one warning");
1193 assert!(
1194 result[0].message.contains("missing.md"),
1195 "Warning should be for missing.md"
1196 );
1197 assert!(
1198 !result.iter().any(|w| w.message.contains("another-missing.md")),
1199 "Should not warn about link in code span"
1200 );
1201 assert!(
1202 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1203 "Should not warn about link in inline code"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_extensionless_link_resolution() {
1209 let temp_dir = tempdir().unwrap();
1211 let base_path = temp_dir.path();
1212
1213 let page_path = base_path.join("page.md");
1215 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1216
1217 let content = r#"
1219# Test Document
1220
1221[Link without extension](page)
1222[Link with extension](page.md)
1223[Missing link](nonexistent)
1224"#;
1225
1226 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1227
1228 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1229 let result = rule.check(&ctx).unwrap();
1230
1231 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1234 assert!(
1235 result[0].message.contains("nonexistent"),
1236 "Warning should be for 'nonexistent' not 'page'"
1237 );
1238 }
1239
1240 #[test]
1242 fn test_cross_file_scope() {
1243 let rule = MD057ExistingRelativeLinks::new();
1244 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1245 }
1246
1247 #[test]
1248 fn test_contribute_to_index_extracts_markdown_links() {
1249 let rule = MD057ExistingRelativeLinks::new();
1250 let content = r#"
1251# Document
1252
1253[Link to docs](./docs/guide.md)
1254[Link with fragment](./other.md#section)
1255[External link](https://example.com)
1256[Image link](image.png)
1257[Media file](video.mp4)
1258"#;
1259
1260 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1261 let mut index = FileIndex::new();
1262 rule.contribute_to_index(&ctx, &mut index);
1263
1264 assert_eq!(index.cross_file_links.len(), 2);
1266
1267 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1269 assert_eq!(index.cross_file_links[0].fragment, "");
1270
1271 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1273 assert_eq!(index.cross_file_links[1].fragment, "section");
1274 }
1275
1276 #[test]
1277 fn test_contribute_to_index_skips_external_and_anchors() {
1278 let rule = MD057ExistingRelativeLinks::new();
1279 let content = r#"
1280# Document
1281
1282[External](https://example.com)
1283[Another external](http://example.org)
1284[Fragment only](#section)
1285[FTP link](ftp://files.example.com)
1286[Mail link](mailto:test@example.com)
1287[WWW link](www.example.com)
1288"#;
1289
1290 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1291 let mut index = FileIndex::new();
1292 rule.contribute_to_index(&ctx, &mut index);
1293
1294 assert_eq!(index.cross_file_links.len(), 0);
1296 }
1297
1298 #[test]
1299 fn test_cross_file_check_valid_link() {
1300 use crate::workspace_index::WorkspaceIndex;
1301
1302 let rule = MD057ExistingRelativeLinks::new();
1303
1304 let mut workspace_index = WorkspaceIndex::new();
1306 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1307
1308 let mut file_index = FileIndex::new();
1310 file_index.add_cross_file_link(CrossFileLinkIndex {
1311 target_path: "guide.md".to_string(),
1312 fragment: "".to_string(),
1313 line: 5,
1314 column: 1,
1315 });
1316
1317 let warnings = rule
1319 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1320 .unwrap();
1321
1322 assert!(warnings.is_empty());
1324 }
1325
1326 #[test]
1327 fn test_cross_file_check_missing_link() {
1328 use crate::workspace_index::WorkspaceIndex;
1329
1330 let rule = MD057ExistingRelativeLinks::new();
1331
1332 let workspace_index = WorkspaceIndex::new();
1334
1335 let mut file_index = FileIndex::new();
1337 file_index.add_cross_file_link(CrossFileLinkIndex {
1338 target_path: "missing.md".to_string(),
1339 fragment: "".to_string(),
1340 line: 5,
1341 column: 1,
1342 });
1343
1344 let warnings = rule
1346 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1347 .unwrap();
1348
1349 assert_eq!(warnings.len(), 1);
1351 assert!(warnings[0].message.contains("missing.md"));
1352 assert!(warnings[0].message.contains("does not exist"));
1353 }
1354
1355 #[test]
1356 fn test_cross_file_check_parent_path() {
1357 use crate::workspace_index::WorkspaceIndex;
1358
1359 let rule = MD057ExistingRelativeLinks::new();
1360
1361 let mut workspace_index = WorkspaceIndex::new();
1363 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1364
1365 let mut file_index = FileIndex::new();
1367 file_index.add_cross_file_link(CrossFileLinkIndex {
1368 target_path: "../readme.md".to_string(),
1369 fragment: "".to_string(),
1370 line: 5,
1371 column: 1,
1372 });
1373
1374 let warnings = rule
1376 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1377 .unwrap();
1378
1379 assert!(warnings.is_empty());
1381 }
1382
1383 #[test]
1384 fn test_normalize_path_function() {
1385 assert_eq!(
1387 normalize_path(Path::new("docs/guide.md")),
1388 PathBuf::from("docs/guide.md")
1389 );
1390
1391 assert_eq!(
1393 normalize_path(Path::new("./docs/guide.md")),
1394 PathBuf::from("docs/guide.md")
1395 );
1396
1397 assert_eq!(
1399 normalize_path(Path::new("docs/sub/../guide.md")),
1400 PathBuf::from("docs/guide.md")
1401 );
1402
1403 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1405 }
1406
1407 #[test]
1408 fn test_resolve_absolute_link() {
1409 let temp_dir = tempdir().expect("Failed to create temp dir");
1411 let root = temp_dir.path();
1412
1413 let contributing = root.join("CONTRIBUTING.md");
1415 File::create(&contributing).expect("Failed to create CONTRIBUTING.md");
1416
1417 let docs = root.join("docs");
1419 std::fs::create_dir(&docs).expect("Failed to create docs dir");
1420 let readme = docs.join("README.md");
1421 File::create(&readme).expect("Failed to create README.md");
1422
1423 let resolved = resolve_absolute_link(&readme, "CONTRIBUTING.md");
1426 assert!(resolved.exists(), "Should find CONTRIBUTING.md at workspace root");
1427 assert_eq!(resolved, contributing);
1428
1429 let nonexistent = resolve_absolute_link(&readme, "NONEXISTENT.md");
1431 assert!(!nonexistent.exists(), "Should not find nonexistent file");
1432 }
1433}