1use crate::rule::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::element_cache::ElementCache;
8use crate::workspace_index::{FileIndex, extract_cross_file_links};
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#[derive(Debug, Clone, Default)]
110pub struct MD057ExistingRelativeLinks {
111 base_path: Arc<Mutex<Option<PathBuf>>>,
113}
114
115impl MD057ExistingRelativeLinks {
116 pub fn new() -> Self {
118 Self::default()
119 }
120
121 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
123 let path = path.as_ref();
124 let dir_path = if path.is_file() {
125 path.parent().map(|p| p.to_path_buf())
126 } else {
127 Some(path.to_path_buf())
128 };
129
130 if let Ok(mut guard) = self.base_path.lock() {
131 *guard = dir_path;
132 }
133 self
134 }
135
136 #[allow(unused_variables)]
137 pub fn from_config_struct(config: MD057Config) -> Self {
138 Self::default()
139 }
140
141 #[inline]
153 fn is_external_url(&self, url: &str) -> bool {
154 if url.is_empty() {
155 return false;
156 }
157
158 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
160 return true;
161 }
162
163 if url.starts_with("{{") || url.starts_with("{%") {
166 return true;
167 }
168
169 if url.contains('@') {
172 return true; }
174
175 if url.ends_with(".com") {
182 return true;
183 }
184
185 if url.starts_with('/') {
189 return true;
190 }
191
192 if url.starts_with('~') || url.starts_with('@') {
196 return true;
197 }
198
199 false
201 }
202
203 #[inline]
205 fn is_fragment_only_link(&self, url: &str) -> bool {
206 url.starts_with('#')
207 }
208
209 fn url_decode(path: &str) -> String {
213 if !path.contains('%') {
215 return path.to_string();
216 }
217
218 let bytes = path.as_bytes();
219 let mut result = Vec::with_capacity(bytes.len());
220 let mut i = 0;
221
222 while i < bytes.len() {
223 if bytes[i] == b'%' && i + 2 < bytes.len() {
224 let hex1 = bytes[i + 1];
226 let hex2 = bytes[i + 2];
227 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
228 result.push(d1 * 16 + d2);
229 i += 3;
230 continue;
231 }
232 }
233 result.push(bytes[i]);
234 i += 1;
235 }
236
237 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
239 }
240
241 fn strip_query_and_fragment(url: &str) -> &str {
249 let query_pos = url.find('?');
252 let fragment_pos = url.find('#');
253
254 match (query_pos, fragment_pos) {
255 (Some(q), Some(f)) => {
256 &url[..q.min(f)]
258 }
259 (Some(q), None) => &url[..q],
260 (None, Some(f)) => &url[..f],
261 (None, None) => url,
262 }
263 }
264
265 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
267 base_path.join(link)
268 }
269}
270
271impl Rule for MD057ExistingRelativeLinks {
272 fn name(&self) -> &'static str {
273 "MD057"
274 }
275
276 fn description(&self) -> &'static str {
277 "Relative links should point to existing files"
278 }
279
280 fn category(&self) -> RuleCategory {
281 RuleCategory::Link
282 }
283
284 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
285 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
286 }
287
288 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
289 let content = ctx.content;
290
291 if content.is_empty() || !content.contains('[') {
293 return Ok(Vec::new());
294 }
295
296 if !content.contains("](") && !content.contains("]:") {
299 return Ok(Vec::new());
300 }
301
302 reset_file_existence_cache();
304
305 let mut warnings = Vec::new();
306
307 let base_path: Option<PathBuf> = {
311 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
313 if explicit_base.is_some() {
314 explicit_base
315 } else if let Some(ref source_file) = ctx.source_file {
316 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
320 resolved_file
321 .parent()
322 .map(|p| p.to_path_buf())
323 .or_else(|| Some(CURRENT_DIR.clone()))
324 } else {
325 None
327 }
328 };
329
330 let Some(base_path) = base_path else {
332 return Ok(warnings);
333 };
334
335 if !ctx.links.is_empty() {
337 let line_index = &ctx.line_index;
339
340 let element_cache = ElementCache::new(content);
342
343 let lines: Vec<&str> = content.lines().collect();
345
346 let mut processed_lines = std::collections::HashSet::new();
349
350 for link in &ctx.links {
351 let line_idx = link.line - 1;
352 if line_idx >= lines.len() {
353 continue;
354 }
355
356 if !processed_lines.insert(line_idx) {
358 continue;
359 }
360
361 let line = lines[line_idx];
362
363 if !line.contains("](") {
365 continue;
366 }
367
368 for link_match in LINK_START_REGEX.find_iter(line) {
370 let start_pos = link_match.start();
371 let end_pos = link_match.end();
372
373 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
375 let absolute_start_pos = line_start_byte + start_pos;
376
377 if element_cache.is_in_code_span(absolute_start_pos) {
379 continue;
380 }
381
382 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
386 .captures_at(line, end_pos - 1)
387 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
388 .or_else(|| {
389 URL_EXTRACT_REGEX
390 .captures_at(line, end_pos - 1)
391 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
392 });
393
394 if let Some((_caps, url_group)) = caps_and_url {
395 let url = url_group.as_str().trim();
396
397 if url.is_empty() {
399 continue;
400 }
401
402 if url.starts_with('`') && url.ends_with('`') {
406 continue;
407 }
408
409 if self.is_external_url(url) || self.is_fragment_only_link(url) {
411 continue;
412 }
413
414 let file_path = Self::strip_query_and_fragment(url);
416
417 let decoded_path = Self::url_decode(file_path);
419
420 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
422
423 if file_exists_or_markdown_extension(&resolved_path) {
425 continue; }
427
428 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
430 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
431 && let (Some(stem), Some(parent)) = (
432 resolved_path.file_stem().and_then(|s| s.to_str()),
433 resolved_path.parent(),
434 ) {
435 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
436 let source_path = parent.join(format!("{stem}{md_ext}"));
437 file_exists_with_cache(&source_path)
438 })
439 } else {
440 false
441 };
442
443 if has_md_source {
444 continue; }
446
447 let url_start = url_group.start();
451 let url_end = url_group.end();
452
453 warnings.push(LintWarning {
454 rule_name: Some(self.name().to_string()),
455 line: link.line,
456 column: url_start + 1, end_line: link.line,
458 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
460 severity: Severity::Error,
461 fix: None,
462 });
463 }
464 }
465 }
466 }
467
468 for image in &ctx.images {
470 let url = image.url.as_ref();
471
472 if url.is_empty() {
474 continue;
475 }
476
477 if self.is_external_url(url) || self.is_fragment_only_link(url) {
479 continue;
480 }
481
482 let file_path = Self::strip_query_and_fragment(url);
484
485 let decoded_path = Self::url_decode(file_path);
487
488 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
490
491 if file_exists_or_markdown_extension(&resolved_path) {
493 continue; }
495
496 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
498 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
499 && let (Some(stem), Some(parent)) = (
500 resolved_path.file_stem().and_then(|s| s.to_str()),
501 resolved_path.parent(),
502 ) {
503 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
504 let source_path = parent.join(format!("{stem}{md_ext}"));
505 file_exists_with_cache(&source_path)
506 })
507 } else {
508 false
509 };
510
511 if has_md_source {
512 continue; }
514
515 warnings.push(LintWarning {
518 rule_name: Some(self.name().to_string()),
519 line: image.line,
520 column: image.start_col + 1,
521 end_line: image.line,
522 end_column: image.start_col + 1 + url.len(),
523 message: format!("Relative link '{url}' does not exist"),
524 severity: Severity::Error,
525 fix: None,
526 });
527 }
528
529 for ref_def in &ctx.reference_defs {
531 let url = &ref_def.url;
532
533 if url.is_empty() {
535 continue;
536 }
537
538 if self.is_external_url(url) || self.is_fragment_only_link(url) {
540 continue;
541 }
542
543 let file_path = Self::strip_query_and_fragment(url);
545
546 let decoded_path = Self::url_decode(file_path);
548
549 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
551
552 if file_exists_or_markdown_extension(&resolved_path) {
554 continue; }
556
557 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
559 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
560 && let (Some(stem), Some(parent)) = (
561 resolved_path.file_stem().and_then(|s| s.to_str()),
562 resolved_path.parent(),
563 ) {
564 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
565 let source_path = parent.join(format!("{stem}{md_ext}"));
566 file_exists_with_cache(&source_path)
567 })
568 } else {
569 false
570 };
571
572 if has_md_source {
573 continue; }
575
576 let line_idx = ref_def.line - 1;
579 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
580 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
582 });
583
584 warnings.push(LintWarning {
585 rule_name: Some(self.name().to_string()),
586 line: ref_def.line,
587 column,
588 end_line: ref_def.line,
589 end_column: column + url.len(),
590 message: format!("Relative link '{url}' does not exist"),
591 severity: Severity::Error,
592 fix: None,
593 });
594 }
595
596 Ok(warnings)
597 }
598
599 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
600 Ok(ctx.content.to_string())
601 }
602
603 fn as_any(&self) -> &dyn std::any::Any {
604 self
605 }
606
607 fn default_config_section(&self) -> Option<(String, toml::Value)> {
608 None
610 }
611
612 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
613 where
614 Self: Sized,
615 {
616 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
617 Box::new(Self::from_config_struct(rule_config))
618 }
619
620 fn cross_file_scope(&self) -> CrossFileScope {
621 CrossFileScope::Workspace
622 }
623
624 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
625 for link in extract_cross_file_links(ctx) {
628 index.add_cross_file_link(link);
629 }
630 }
631
632 fn cross_file_check(
633 &self,
634 file_path: &Path,
635 file_index: &FileIndex,
636 workspace_index: &crate::workspace_index::WorkspaceIndex,
637 ) -> LintResult {
638 let mut warnings = Vec::new();
639
640 let file_dir = file_path.parent();
642
643 for cross_link in &file_index.cross_file_links {
644 let decoded_target = Self::url_decode(&cross_link.target_path);
647
648 if decoded_target.starts_with('/') {
650 continue;
651 }
652
653 let target_path = if let Some(dir) = file_dir {
655 dir.join(&decoded_target)
656 } else {
657 Path::new(&decoded_target).to_path_buf()
658 };
659
660 let target_path = normalize_path(&target_path);
662
663 let file_exists =
665 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
666
667 if !file_exists {
668 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
702fn 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 if !components.is_empty() {
711 components.pop();
712 }
713 }
714 std::path::Component::CurDir => {
715 }
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 crate::workspace_index::CrossFileLinkIndex;
730 use std::fs::File;
731 use std::io::Write;
732 use tempfile::tempdir;
733
734 #[test]
735 fn test_strip_query_and_fragment() {
736 assert_eq!(
738 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
739 "file.png"
740 );
741 assert_eq!(
742 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
743 "file.png"
744 );
745 assert_eq!(
746 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
747 "file.png"
748 );
749
750 assert_eq!(
752 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
753 "file.md"
754 );
755 assert_eq!(
756 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
757 "file.md"
758 );
759
760 assert_eq!(
762 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
763 "file.md"
764 );
765
766 assert_eq!(
768 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
769 "file.png"
770 );
771
772 assert_eq!(
774 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
775 "path/to/image.png"
776 );
777 assert_eq!(
778 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
779 "path/to/image.png"
780 );
781
782 assert_eq!(
784 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
785 "file.md"
786 );
787 }
788
789 #[test]
790 fn test_url_decode() {
791 assert_eq!(
793 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
794 "penguin with space.jpg"
795 );
796
797 assert_eq!(
799 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
800 "assets/my file name.png"
801 );
802
803 assert_eq!(
805 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
806 "hello world!.md"
807 );
808
809 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
811
812 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
814
815 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
817
818 assert_eq!(
820 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
821 "normal-file.md"
822 );
823
824 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
826
827 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
829
830 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
832
833 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
835
836 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
838
839 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
841
842 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
844
845 assert_eq!(
847 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
848 "path/to/file.md"
849 );
850
851 assert_eq!(
853 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
854 "hello world/foo bar.md"
855 );
856
857 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
859
860 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
862 }
863
864 #[test]
865 fn test_url_encoded_filenames() {
866 let temp_dir = tempdir().unwrap();
868 let base_path = temp_dir.path();
869
870 let file_with_spaces = base_path.join("penguin with space.jpg");
872 File::create(&file_with_spaces)
873 .unwrap()
874 .write_all(b"image data")
875 .unwrap();
876
877 let subdir = base_path.join("my images");
879 std::fs::create_dir(&subdir).unwrap();
880 let nested_file = subdir.join("photo 1.png");
881 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
882
883 let content = r#"
885# Test Document with URL-Encoded Links
886
887
888
889
890"#;
891
892 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
893
894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
895 let result = rule.check(&ctx).unwrap();
896
897 assert_eq!(
899 result.len(),
900 1,
901 "Should only warn about missing%20file.jpg. Got: {result:?}"
902 );
903 assert!(
904 result[0].message.contains("missing%20file.jpg"),
905 "Warning should mention the URL-encoded filename"
906 );
907 }
908
909 #[test]
910 fn test_external_urls() {
911 let rule = MD057ExistingRelativeLinks::new();
912
913 assert!(rule.is_external_url("https://example.com"));
915 assert!(rule.is_external_url("http://example.com"));
916 assert!(rule.is_external_url("ftp://example.com"));
917 assert!(rule.is_external_url("www.example.com"));
918 assert!(rule.is_external_url("example.com"));
919
920 assert!(rule.is_external_url("file:///path/to/file"));
922 assert!(rule.is_external_url("smb://server/share"));
923 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
924 assert!(rule.is_external_url("mailto:user@example.com"));
925 assert!(rule.is_external_url("tel:+1234567890"));
926 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
927 assert!(rule.is_external_url("javascript:void(0)"));
928 assert!(rule.is_external_url("ssh://git@github.com/repo"));
929 assert!(rule.is_external_url("git://github.com/repo.git"));
930
931 assert!(rule.is_external_url("user@example.com"));
934 assert!(rule.is_external_url("steering@kubernetes.io"));
935 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
936 assert!(rule.is_external_url("user_name@sub.domain.com"));
937 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
938
939 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"));
950 assert!(rule.is_external_url("/blog/2024/release.html"));
951 assert!(rule.is_external_url("/react/hooks/use-state.html"));
952 assert!(rule.is_external_url("/pkg/runtime"));
953 assert!(rule.is_external_url("/doc/go1compat"));
954 assert!(rule.is_external_url("/index.html"));
955 assert!(rule.is_external_url("/assets/logo.png"));
956
957 assert!(rule.is_external_url("~/assets/image.png"));
960 assert!(rule.is_external_url("~/components/Button.vue"));
961 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
965 assert!(rule.is_external_url("@images/photo.jpg"));
966 assert!(rule.is_external_url("@assets/styles.css"));
967
968 assert!(!rule.is_external_url("./relative/path.md"));
970 assert!(!rule.is_external_url("relative/path.md"));
971 assert!(!rule.is_external_url("../parent/path.md"));
972 }
973
974 #[test]
975 fn test_framework_path_aliases() {
976 let temp_dir = tempdir().unwrap();
978 let base_path = temp_dir.path();
979
980 let content = r#"
982# Framework Path Aliases
983
984
985
986
987
988[Link](@/pages/about.md)
989
990This is a [real missing link](missing.md) that should be flagged.
991"#;
992
993 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
994
995 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
996 let result = rule.check(&ctx).unwrap();
997
998 assert_eq!(
1000 result.len(),
1001 1,
1002 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1003 );
1004 assert!(
1005 result[0].message.contains("missing.md"),
1006 "Warning should be for missing.md"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_url_decode_security_path_traversal() {
1012 let temp_dir = tempdir().unwrap();
1015 let base_path = temp_dir.path();
1016
1017 let file_in_base = base_path.join("safe.md");
1019 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1020
1021 let content = r#"
1026[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1027[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1028[Safe link](safe.md)
1029"#;
1030
1031 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1032
1033 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1034 let result = rule.check(&ctx).unwrap();
1035
1036 assert_eq!(
1039 result.len(),
1040 2,
1041 "Should have warnings for traversal attempts. Got: {result:?}"
1042 );
1043 }
1044
1045 #[test]
1046 fn test_url_encoded_utf8_filenames() {
1047 let temp_dir = tempdir().unwrap();
1049 let base_path = temp_dir.path();
1050
1051 let cafe_file = base_path.join("café.md");
1053 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1054
1055 let content = r#"
1056[Café link](caf%C3%A9.md)
1057[Missing unicode](r%C3%A9sum%C3%A9.md)
1058"#;
1059
1060 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1061
1062 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1063 let result = rule.check(&ctx).unwrap();
1064
1065 assert_eq!(
1067 result.len(),
1068 1,
1069 "Should only warn about missing résumé.md. Got: {result:?}"
1070 );
1071 assert!(
1072 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1073 "Warning should mention the URL-encoded filename"
1074 );
1075 }
1076
1077 #[test]
1078 fn test_url_encoded_emoji_filenames() {
1079 let temp_dir = tempdir().unwrap();
1082 let base_path = temp_dir.path();
1083
1084 let emoji_dir = base_path.join("👤 Personal");
1086 std::fs::create_dir(&emoji_dir).unwrap();
1087
1088 let file_path = emoji_dir.join("TV Shows.md");
1090 File::create(&file_path)
1091 .unwrap()
1092 .write_all(b"# TV Shows\n\nContent here.")
1093 .unwrap();
1094
1095 let content = r#"
1098# Test Document
1099
1100[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1101[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1102"#;
1103
1104 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1105
1106 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1107 let result = rule.check(&ctx).unwrap();
1108
1109 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1111 assert!(
1112 result[0].message.contains("Missing.md"),
1113 "Warning should be for Missing.md, got: {}",
1114 result[0].message
1115 );
1116 }
1117
1118 #[test]
1119 fn test_no_warnings_without_base_path() {
1120 let rule = MD057ExistingRelativeLinks::new();
1121 let content = "[Link](missing.md)";
1122
1123 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1124 let result = rule.check(&ctx).unwrap();
1125 assert!(result.is_empty(), "Should have no warnings without base path");
1126 }
1127
1128 #[test]
1129 fn test_existing_and_missing_links() {
1130 let temp_dir = tempdir().unwrap();
1132 let base_path = temp_dir.path();
1133
1134 let exists_path = base_path.join("exists.md");
1136 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1137
1138 assert!(exists_path.exists(), "exists.md should exist for this test");
1140
1141 let content = r#"
1143# Test Document
1144
1145[Valid Link](exists.md)
1146[Invalid Link](missing.md)
1147[External Link](https://example.com)
1148[Media Link](image.jpg)
1149 "#;
1150
1151 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1153
1154 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156 let result = rule.check(&ctx).unwrap();
1157
1158 assert_eq!(result.len(), 2);
1160 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1161 assert!(messages.iter().any(|m| m.contains("missing.md")));
1162 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1163 }
1164
1165 #[test]
1166 fn test_angle_bracket_links() {
1167 let temp_dir = tempdir().unwrap();
1169 let base_path = temp_dir.path();
1170
1171 let exists_path = base_path.join("exists.md");
1173 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1174
1175 let content = r#"
1177# Test Document
1178
1179[Valid Link](<exists.md>)
1180[Invalid Link](<missing.md>)
1181[External Link](<https://example.com>)
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);
1188 let result = rule.check(&ctx).unwrap();
1189
1190 assert_eq!(result.len(), 1, "Should have exactly one warning");
1192 assert!(
1193 result[0].message.contains("missing.md"),
1194 "Warning should mention missing.md"
1195 );
1196 }
1197
1198 #[test]
1199 fn test_angle_bracket_links_with_parens() {
1200 let temp_dir = tempdir().unwrap();
1202 let base_path = temp_dir.path();
1203
1204 let app_dir = base_path.join("app");
1206 std::fs::create_dir(&app_dir).unwrap();
1207 let upload_dir = app_dir.join("(upload)");
1208 std::fs::create_dir(&upload_dir).unwrap();
1209 let page_file = upload_dir.join("page.tsx");
1210 File::create(&page_file)
1211 .unwrap()
1212 .write_all(b"export default function Page() {}")
1213 .unwrap();
1214
1215 let content = r#"
1217# Test Document with Paths Containing Parens
1218
1219[Upload Page](<app/(upload)/page.tsx>)
1220[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1221[Missing](<app/(missing)/file.md>)
1222"#;
1223
1224 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1225
1226 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1227 let result = rule.check(&ctx).unwrap();
1228
1229 assert_eq!(
1231 result.len(),
1232 1,
1233 "Should have exactly one warning for missing file. Got: {result:?}"
1234 );
1235 assert!(
1236 result[0].message.contains("app/(missing)/file.md"),
1237 "Warning should mention app/(missing)/file.md"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_all_file_types_checked() {
1243 let temp_dir = tempdir().unwrap();
1245 let base_path = temp_dir.path();
1246
1247 let content = r#"
1249[Image Link](image.jpg)
1250[Video Link](video.mp4)
1251[Markdown Link](document.md)
1252[PDF Link](file.pdf)
1253"#;
1254
1255 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1256
1257 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258 let result = rule.check(&ctx).unwrap();
1259
1260 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1262 }
1263
1264 #[test]
1265 fn test_code_span_detection() {
1266 let rule = MD057ExistingRelativeLinks::new();
1267
1268 let temp_dir = tempdir().unwrap();
1270 let base_path = temp_dir.path();
1271
1272 let rule = rule.with_path(base_path);
1273
1274 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1276
1277 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1278 let result = rule.check(&ctx).unwrap();
1279
1280 assert_eq!(result.len(), 1, "Should only flag the real link");
1282 assert!(result[0].message.contains("nonexistent.md"));
1283 }
1284
1285 #[test]
1286 fn test_inline_code_spans() {
1287 let temp_dir = tempdir().unwrap();
1289 let base_path = temp_dir.path();
1290
1291 let content = r#"
1293# Test Document
1294
1295This is a normal link: [Link](missing.md)
1296
1297This is a code span with a link: `[Link](another-missing.md)`
1298
1299Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1300
1301 "#;
1302
1303 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1305
1306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1308 let result = rule.check(&ctx).unwrap();
1309
1310 assert_eq!(result.len(), 1, "Should have exactly one warning");
1312 assert!(
1313 result[0].message.contains("missing.md"),
1314 "Warning should be for missing.md"
1315 );
1316 assert!(
1317 !result.iter().any(|w| w.message.contains("another-missing.md")),
1318 "Should not warn about link in code span"
1319 );
1320 assert!(
1321 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1322 "Should not warn about link in inline code"
1323 );
1324 }
1325
1326 #[test]
1327 fn test_extensionless_link_resolution() {
1328 let temp_dir = tempdir().unwrap();
1330 let base_path = temp_dir.path();
1331
1332 let page_path = base_path.join("page.md");
1334 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1335
1336 let content = r#"
1338# Test Document
1339
1340[Link without extension](page)
1341[Link with extension](page.md)
1342[Missing link](nonexistent)
1343"#;
1344
1345 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1346
1347 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1348 let result = rule.check(&ctx).unwrap();
1349
1350 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1353 assert!(
1354 result[0].message.contains("nonexistent"),
1355 "Warning should be for 'nonexistent' not 'page'"
1356 );
1357 }
1358
1359 #[test]
1361 fn test_cross_file_scope() {
1362 let rule = MD057ExistingRelativeLinks::new();
1363 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1364 }
1365
1366 #[test]
1367 fn test_contribute_to_index_extracts_markdown_links() {
1368 let rule = MD057ExistingRelativeLinks::new();
1369 let content = r#"
1370# Document
1371
1372[Link to docs](./docs/guide.md)
1373[Link with fragment](./other.md#section)
1374[External link](https://example.com)
1375[Image link](image.png)
1376[Media file](video.mp4)
1377"#;
1378
1379 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1380 let mut index = FileIndex::new();
1381 rule.contribute_to_index(&ctx, &mut index);
1382
1383 assert_eq!(index.cross_file_links.len(), 2);
1385
1386 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1388 assert_eq!(index.cross_file_links[0].fragment, "");
1389
1390 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1392 assert_eq!(index.cross_file_links[1].fragment, "section");
1393 }
1394
1395 #[test]
1396 fn test_contribute_to_index_skips_external_and_anchors() {
1397 let rule = MD057ExistingRelativeLinks::new();
1398 let content = r#"
1399# Document
1400
1401[External](https://example.com)
1402[Another external](http://example.org)
1403[Fragment only](#section)
1404[FTP link](ftp://files.example.com)
1405[Mail link](mailto:test@example.com)
1406[WWW link](www.example.com)
1407"#;
1408
1409 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410 let mut index = FileIndex::new();
1411 rule.contribute_to_index(&ctx, &mut index);
1412
1413 assert_eq!(index.cross_file_links.len(), 0);
1415 }
1416
1417 #[test]
1418 fn test_cross_file_check_valid_link() {
1419 use crate::workspace_index::WorkspaceIndex;
1420
1421 let rule = MD057ExistingRelativeLinks::new();
1422
1423 let mut workspace_index = WorkspaceIndex::new();
1425 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1426
1427 let mut file_index = FileIndex::new();
1429 file_index.add_cross_file_link(CrossFileLinkIndex {
1430 target_path: "guide.md".to_string(),
1431 fragment: "".to_string(),
1432 line: 5,
1433 column: 1,
1434 });
1435
1436 let warnings = rule
1438 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1439 .unwrap();
1440
1441 assert!(warnings.is_empty());
1443 }
1444
1445 #[test]
1446 fn test_cross_file_check_missing_link() {
1447 use crate::workspace_index::WorkspaceIndex;
1448
1449 let rule = MD057ExistingRelativeLinks::new();
1450
1451 let workspace_index = WorkspaceIndex::new();
1453
1454 let mut file_index = FileIndex::new();
1456 file_index.add_cross_file_link(CrossFileLinkIndex {
1457 target_path: "missing.md".to_string(),
1458 fragment: "".to_string(),
1459 line: 5,
1460 column: 1,
1461 });
1462
1463 let warnings = rule
1465 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1466 .unwrap();
1467
1468 assert_eq!(warnings.len(), 1);
1470 assert!(warnings[0].message.contains("missing.md"));
1471 assert!(warnings[0].message.contains("does not exist"));
1472 }
1473
1474 #[test]
1475 fn test_cross_file_check_parent_path() {
1476 use crate::workspace_index::WorkspaceIndex;
1477
1478 let rule = MD057ExistingRelativeLinks::new();
1479
1480 let mut workspace_index = WorkspaceIndex::new();
1482 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1483
1484 let mut file_index = FileIndex::new();
1486 file_index.add_cross_file_link(CrossFileLinkIndex {
1487 target_path: "../readme.md".to_string(),
1488 fragment: "".to_string(),
1489 line: 5,
1490 column: 1,
1491 });
1492
1493 let warnings = rule
1495 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1496 .unwrap();
1497
1498 assert!(warnings.is_empty());
1500 }
1501
1502 #[test]
1503 fn test_cross_file_check_html_link_with_md_source() {
1504 use crate::workspace_index::WorkspaceIndex;
1507
1508 let rule = MD057ExistingRelativeLinks::new();
1509
1510 let mut workspace_index = WorkspaceIndex::new();
1512 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1513
1514 let mut file_index = FileIndex::new();
1516 file_index.add_cross_file_link(CrossFileLinkIndex {
1517 target_path: "guide.html".to_string(),
1518 fragment: "section".to_string(),
1519 line: 10,
1520 column: 5,
1521 });
1522
1523 let warnings = rule
1525 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1526 .unwrap();
1527
1528 assert!(
1530 warnings.is_empty(),
1531 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1532 );
1533 }
1534
1535 #[test]
1536 fn test_cross_file_check_html_link_without_source() {
1537 use crate::workspace_index::WorkspaceIndex;
1539
1540 let rule = MD057ExistingRelativeLinks::new();
1541
1542 let workspace_index = WorkspaceIndex::new();
1544
1545 let mut file_index = FileIndex::new();
1547 file_index.add_cross_file_link(CrossFileLinkIndex {
1548 target_path: "missing.html".to_string(),
1549 fragment: "".to_string(),
1550 line: 10,
1551 column: 5,
1552 });
1553
1554 let warnings = rule
1556 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1557 .unwrap();
1558
1559 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1561 assert!(warnings[0].message.contains("missing.html"));
1562 }
1563
1564 #[test]
1565 fn test_normalize_path_function() {
1566 assert_eq!(
1568 normalize_path(Path::new("docs/guide.md")),
1569 PathBuf::from("docs/guide.md")
1570 );
1571
1572 assert_eq!(
1574 normalize_path(Path::new("./docs/guide.md")),
1575 PathBuf::from("docs/guide.md")
1576 );
1577
1578 assert_eq!(
1580 normalize_path(Path::new("docs/sub/../guide.md")),
1581 PathBuf::from("docs/guide.md")
1582 );
1583
1584 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1586 }
1587
1588 #[test]
1589 fn test_html_link_with_md_source() {
1590 let temp_dir = tempdir().unwrap();
1592 let base_path = temp_dir.path();
1593
1594 let md_file = base_path.join("guide.md");
1596 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1597
1598 let content = r#"
1599[Read the guide](guide.html)
1600[Also here](getting-started.html)
1601"#;
1602
1603 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1604 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605 let result = rule.check(&ctx).unwrap();
1606
1607 assert_eq!(
1609 result.len(),
1610 1,
1611 "Should only warn about missing source. Got: {result:?}"
1612 );
1613 assert!(result[0].message.contains("getting-started.html"));
1614 }
1615
1616 #[test]
1617 fn test_htm_link_with_md_source() {
1618 let temp_dir = tempdir().unwrap();
1620 let base_path = temp_dir.path();
1621
1622 let md_file = base_path.join("page.md");
1623 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1624
1625 let content = "[Page](page.htm)";
1626
1627 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1628 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1629 let result = rule.check(&ctx).unwrap();
1630
1631 assert!(
1632 result.is_empty(),
1633 "Should not warn when .md source exists for .htm link"
1634 );
1635 }
1636
1637 #[test]
1638 fn test_html_link_finds_various_markdown_extensions() {
1639 let temp_dir = tempdir().unwrap();
1641 let base_path = temp_dir.path();
1642
1643 File::create(base_path.join("doc.md")).unwrap();
1644 File::create(base_path.join("tutorial.mdx")).unwrap();
1645 File::create(base_path.join("guide.markdown")).unwrap();
1646
1647 let content = r#"
1648[Doc](doc.html)
1649[Tutorial](tutorial.html)
1650[Guide](guide.html)
1651"#;
1652
1653 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1655 let result = rule.check(&ctx).unwrap();
1656
1657 assert!(
1658 result.is_empty(),
1659 "Should find all markdown variants as source files. Got: {result:?}"
1660 );
1661 }
1662
1663 #[test]
1664 fn test_html_link_in_subdirectory() {
1665 let temp_dir = tempdir().unwrap();
1667 let base_path = temp_dir.path();
1668
1669 let docs_dir = base_path.join("docs");
1670 std::fs::create_dir(&docs_dir).unwrap();
1671 File::create(docs_dir.join("guide.md"))
1672 .unwrap()
1673 .write_all(b"# Guide")
1674 .unwrap();
1675
1676 let content = "[Guide](docs/guide.html)";
1677
1678 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1680 let result = rule.check(&ctx).unwrap();
1681
1682 assert!(result.is_empty(), "Should find markdown source in subdirectory");
1683 }
1684
1685 #[test]
1686 fn test_absolute_path_skipped_in_check() {
1687 let temp_dir = tempdir().unwrap();
1690 let base_path = temp_dir.path();
1691
1692 let content = r#"
1693# Test Document
1694
1695[Go Runtime](/pkg/runtime)
1696[Go Runtime with Fragment](/pkg/runtime#section)
1697[API Docs](/api/v1/users)
1698[Blog Post](/blog/2024/release.html)
1699[React Hook](/react/hooks/use-state.html)
1700"#;
1701
1702 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1703 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1704 let result = rule.check(&ctx).unwrap();
1705
1706 assert!(
1708 result.is_empty(),
1709 "Absolute paths should be skipped. Got warnings: {result:?}"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_absolute_path_skipped_in_cross_file_check() {
1715 use crate::workspace_index::WorkspaceIndex;
1717
1718 let rule = MD057ExistingRelativeLinks::new();
1719
1720 let workspace_index = WorkspaceIndex::new();
1722
1723 let mut file_index = FileIndex::new();
1725 file_index.add_cross_file_link(CrossFileLinkIndex {
1726 target_path: "/pkg/runtime.md".to_string(),
1727 fragment: "".to_string(),
1728 line: 5,
1729 column: 1,
1730 });
1731 file_index.add_cross_file_link(CrossFileLinkIndex {
1732 target_path: "/api/v1/users.md".to_string(),
1733 fragment: "section".to_string(),
1734 line: 10,
1735 column: 1,
1736 });
1737
1738 let warnings = rule
1740 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1741 .unwrap();
1742
1743 assert!(
1745 warnings.is_empty(),
1746 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1747 );
1748 }
1749
1750 #[test]
1751 fn test_protocol_relative_url_not_skipped() {
1752 let temp_dir = tempdir().unwrap();
1755 let base_path = temp_dir.path();
1756
1757 let content = r#"
1758# Test Document
1759
1760[External](//example.com/page)
1761[Another](//cdn.example.com/asset.js)
1762"#;
1763
1764 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1765 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1766 let result = rule.check(&ctx).unwrap();
1767
1768 assert!(
1770 result.is_empty(),
1771 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
1772 );
1773 }
1774
1775 #[test]
1776 fn test_email_addresses_skipped() {
1777 let temp_dir = tempdir().unwrap();
1780 let base_path = temp_dir.path();
1781
1782 let content = r#"
1783# Test Document
1784
1785[Contact](user@example.com)
1786[Steering](steering@kubernetes.io)
1787[Support](john.doe+filter@company.co.uk)
1788[User](user_name@sub.domain.com)
1789"#;
1790
1791 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1792 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let result = rule.check(&ctx).unwrap();
1794
1795 assert!(
1797 result.is_empty(),
1798 "Email addresses should be skipped. Got warnings: {result:?}"
1799 );
1800 }
1801
1802 #[test]
1803 fn test_email_addresses_vs_file_paths() {
1804 let temp_dir = tempdir().unwrap();
1807 let base_path = temp_dir.path();
1808
1809 let content = r#"
1810# Test Document
1811
1812[Email](user@example.com) <!-- Should be skipped (email) -->
1813[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
1814[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
1815"#;
1816
1817 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert!(
1823 result.is_empty(),
1824 "All email addresses should be skipped. Got: {result:?}"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_diagnostic_position_accuracy() {
1830 let temp_dir = tempdir().unwrap();
1832 let base_path = temp_dir.path();
1833
1834 let content = "prefix [text](missing.md) suffix";
1837 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let result = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(result.len(), 1, "Should have exactly one warning");
1845 assert_eq!(result[0].line, 1, "Should be on line 1");
1846 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
1847 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
1848 }
1849
1850 #[test]
1851 fn test_diagnostic_position_angle_brackets() {
1852 let temp_dir = tempdir().unwrap();
1854 let base_path = temp_dir.path();
1855
1856 let content = "[link](<missing.md>)";
1859 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1862 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864
1865 assert_eq!(result.len(), 1, "Should have exactly one warning");
1866 assert_eq!(result[0].line, 1, "Should be on line 1");
1867 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
1868 }
1869
1870 #[test]
1871 fn test_diagnostic_position_multiline() {
1872 let temp_dir = tempdir().unwrap();
1874 let base_path = temp_dir.path();
1875
1876 let content = r#"# Title
1877Some text on line 2
1878[link on line 3](missing1.md)
1879More text
1880[link on line 5](missing2.md)"#;
1881
1882 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1884 let result = rule.check(&ctx).unwrap();
1885
1886 assert_eq!(result.len(), 2, "Should have two warnings");
1887
1888 assert_eq!(result[0].line, 3, "First warning should be on line 3");
1890 assert!(result[0].message.contains("missing1.md"));
1891
1892 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
1894 assert!(result[1].message.contains("missing2.md"));
1895 }
1896
1897 #[test]
1898 fn test_diagnostic_position_with_spaces() {
1899 let temp_dir = tempdir().unwrap();
1901 let base_path = temp_dir.path();
1902
1903 let content = "[link]( missing.md )";
1904 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1909 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1910 let result = rule.check(&ctx).unwrap();
1911
1912 assert_eq!(result.len(), 1, "Should have exactly one warning");
1913 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
1915 }
1916
1917 #[test]
1918 fn test_diagnostic_position_image() {
1919 let temp_dir = tempdir().unwrap();
1921 let base_path = temp_dir.path();
1922
1923 let content = "";
1924
1925 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1926 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1927 let result = rule.check(&ctx).unwrap();
1928
1929 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
1930 assert_eq!(result[0].line, 1);
1931 assert!(result[0].column > 0, "Should have valid column position");
1933 assert!(result[0].message.contains("missing.jpg"));
1934 }
1935
1936 #[test]
1937 fn test_wikilinks_skipped() {
1938 let temp_dir = tempdir().unwrap();
1941 let base_path = temp_dir.path();
1942
1943 let content = r#"# Test Document
1944
1945[[Microsoft#Windows OS]]
1946[[SomePage]]
1947[[Page With Spaces]]
1948[[path/to/page#section]]
1949[[page|Display Text]]
1950
1951This is a [real missing link](missing.md) that should be flagged.
1952"#;
1953
1954 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1955 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1956 let result = rule.check(&ctx).unwrap();
1957
1958 assert_eq!(
1960 result.len(),
1961 1,
1962 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
1963 );
1964 assert!(
1965 result[0].message.contains("missing.md"),
1966 "Warning should be for missing.md, not wikilinks"
1967 );
1968 }
1969
1970 #[test]
1971 fn test_wikilinks_not_added_to_index() {
1972 let temp_dir = tempdir().unwrap();
1974 let base_path = temp_dir.path();
1975
1976 let content = r#"# Test Document
1977
1978[[Microsoft#Windows OS]]
1979[[SomePage#section]]
1980[Regular Link](other.md)
1981"#;
1982
1983 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1984 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1985
1986 let mut file_index = FileIndex::new();
1987 rule.contribute_to_index(&ctx, &mut file_index);
1988
1989 let cross_file_links = &file_index.cross_file_links;
1992 assert_eq!(
1993 cross_file_links.len(),
1994 1,
1995 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1996 );
1997 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1998 }
1999
2000 #[test]
2001 fn test_reference_definition_missing_file() {
2002 let temp_dir = tempdir().unwrap();
2004 let base_path = temp_dir.path();
2005
2006 let content = r#"# Test Document
2007
2008[test]: ./missing.md
2009[example]: ./nonexistent.html
2010
2011Use [test] and [example] here.
2012"#;
2013
2014 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2015 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2016 let result = rule.check(&ctx).unwrap();
2017
2018 assert_eq!(
2020 result.len(),
2021 2,
2022 "Should have warnings for missing reference definition targets. Got: {result:?}"
2023 );
2024 assert!(
2025 result.iter().any(|w| w.message.contains("missing.md")),
2026 "Should warn about missing.md"
2027 );
2028 assert!(
2029 result.iter().any(|w| w.message.contains("nonexistent.html")),
2030 "Should warn about nonexistent.html"
2031 );
2032 }
2033
2034 #[test]
2035 fn test_reference_definition_existing_file() {
2036 let temp_dir = tempdir().unwrap();
2038 let base_path = temp_dir.path();
2039
2040 let exists_path = base_path.join("exists.md");
2042 File::create(&exists_path)
2043 .unwrap()
2044 .write_all(b"# Existing file")
2045 .unwrap();
2046
2047 let content = r#"# Test Document
2048
2049[test]: ./exists.md
2050
2051Use [test] here.
2052"#;
2053
2054 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2055 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2056 let result = rule.check(&ctx).unwrap();
2057
2058 assert!(
2060 result.is_empty(),
2061 "Should not warn about existing file. Got: {result:?}"
2062 );
2063 }
2064
2065 #[test]
2066 fn test_reference_definition_external_url_skipped() {
2067 let temp_dir = tempdir().unwrap();
2069 let base_path = temp_dir.path();
2070
2071 let content = r#"# Test Document
2072
2073[google]: https://google.com
2074[example]: http://example.org
2075[mail]: mailto:test@example.com
2076[ftp]: ftp://files.example.com
2077[local]: ./missing.md
2078
2079Use [google], [example], [mail], [ftp], [local] here.
2080"#;
2081
2082 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2083 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2084 let result = rule.check(&ctx).unwrap();
2085
2086 assert_eq!(
2088 result.len(),
2089 1,
2090 "Should only warn about local missing file. Got: {result:?}"
2091 );
2092 assert!(
2093 result[0].message.contains("missing.md"),
2094 "Warning should be for missing.md"
2095 );
2096 }
2097
2098 #[test]
2099 fn test_reference_definition_fragment_only_skipped() {
2100 let temp_dir = tempdir().unwrap();
2102 let base_path = temp_dir.path();
2103
2104 let content = r#"# Test Document
2105
2106[section]: #my-section
2107
2108Use [section] here.
2109"#;
2110
2111 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2112 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2113 let result = rule.check(&ctx).unwrap();
2114
2115 assert!(
2117 result.is_empty(),
2118 "Should not warn about fragment-only reference. Got: {result:?}"
2119 );
2120 }
2121
2122 #[test]
2123 fn test_reference_definition_column_position() {
2124 let temp_dir = tempdir().unwrap();
2126 let base_path = temp_dir.path();
2127
2128 let content = "[ref]: ./missing.md";
2131 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2135 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2136 let result = rule.check(&ctx).unwrap();
2137
2138 assert_eq!(result.len(), 1, "Should have exactly one warning");
2139 assert_eq!(result[0].line, 1, "Should be on line 1");
2140 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2141 }
2142
2143 #[test]
2144 fn test_reference_definition_html_with_md_source() {
2145 let temp_dir = tempdir().unwrap();
2147 let base_path = temp_dir.path();
2148
2149 let md_file = base_path.join("guide.md");
2151 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2152
2153 let content = r#"# Test Document
2154
2155[guide]: ./guide.html
2156[missing]: ./missing.html
2157
2158Use [guide] and [missing] here.
2159"#;
2160
2161 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2162 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2163 let result = rule.check(&ctx).unwrap();
2164
2165 assert_eq!(
2167 result.len(),
2168 1,
2169 "Should only warn about missing source. Got: {result:?}"
2170 );
2171 assert!(result[0].message.contains("missing.html"));
2172 }
2173
2174 #[test]
2175 fn test_reference_definition_url_encoded() {
2176 let temp_dir = tempdir().unwrap();
2178 let base_path = temp_dir.path();
2179
2180 let file_with_spaces = base_path.join("file with spaces.md");
2182 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2183
2184 let content = r#"# Test Document
2185
2186[spaces]: ./file%20with%20spaces.md
2187[missing]: ./missing%20file.md
2188
2189Use [spaces] and [missing] here.
2190"#;
2191
2192 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2193 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2194 let result = rule.check(&ctx).unwrap();
2195
2196 assert_eq!(
2198 result.len(),
2199 1,
2200 "Should only warn about missing URL-encoded file. Got: {result:?}"
2201 );
2202 assert!(result[0].message.contains("missing%20file.md"));
2203 }
2204
2205 #[test]
2206 fn test_inline_and_reference_both_checked() {
2207 let temp_dir = tempdir().unwrap();
2209 let base_path = temp_dir.path();
2210
2211 let content = r#"# Test Document
2212
2213[inline link](./inline-missing.md)
2214[ref]: ./ref-missing.md
2215
2216Use [ref] here.
2217"#;
2218
2219 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2220 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2221 let result = rule.check(&ctx).unwrap();
2222
2223 assert_eq!(
2225 result.len(),
2226 2,
2227 "Should warn about both inline and reference links. Got: {result:?}"
2228 );
2229 assert!(
2230 result.iter().any(|w| w.message.contains("inline-missing.md")),
2231 "Should warn about inline-missing.md"
2232 );
2233 assert!(
2234 result.iter().any(|w| w.message.contains("ref-missing.md")),
2235 "Should warn about ref-missing.md"
2236 );
2237 }
2238}