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("](") {
298 return Ok(Vec::new());
299 }
300
301 reset_file_existence_cache();
303
304 let mut warnings = Vec::new();
305
306 let base_path: Option<PathBuf> = {
310 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
312 if explicit_base.is_some() {
313 explicit_base
314 } else if let Some(ref source_file) = ctx.source_file {
315 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
319 resolved_file
320 .parent()
321 .map(|p| p.to_path_buf())
322 .or_else(|| Some(CURRENT_DIR.clone()))
323 } else {
324 None
326 }
327 };
328
329 let Some(base_path) = base_path else {
331 return Ok(warnings);
332 };
333
334 if !ctx.links.is_empty() {
336 let line_index = &ctx.line_index;
338
339 let element_cache = ElementCache::new(content);
341
342 let lines: Vec<&str> = content.lines().collect();
344
345 for link in &ctx.links {
346 let line_idx = link.line - 1;
347 if line_idx >= lines.len() {
348 continue;
349 }
350
351 let line = lines[line_idx];
352
353 if !line.contains("](") {
355 continue;
356 }
357
358 for link_match in LINK_START_REGEX.find_iter(line) {
360 let start_pos = link_match.start();
361 let end_pos = link_match.end();
362
363 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
365 let absolute_start_pos = line_start_byte + start_pos;
366
367 if element_cache.is_in_code_span(absolute_start_pos) {
369 continue;
370 }
371
372 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
376 .captures_at(line, end_pos - 1)
377 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
378 .or_else(|| {
379 URL_EXTRACT_REGEX
380 .captures_at(line, end_pos - 1)
381 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
382 });
383
384 if let Some((_caps, url_group)) = caps_and_url {
385 let url = url_group.as_str().trim();
386
387 if url.is_empty() {
389 continue;
390 }
391
392 if self.is_external_url(url) || self.is_fragment_only_link(url) {
394 continue;
395 }
396
397 let file_path = Self::strip_query_and_fragment(url);
399
400 let decoded_path = Self::url_decode(file_path);
402
403 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
405
406 if file_exists_or_markdown_extension(&resolved_path) {
408 continue; }
410
411 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
413 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
414 && let (Some(stem), Some(parent)) = (
415 resolved_path.file_stem().and_then(|s| s.to_str()),
416 resolved_path.parent(),
417 ) {
418 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
419 let source_path = parent.join(format!("{stem}{md_ext}"));
420 file_exists_with_cache(&source_path)
421 })
422 } else {
423 false
424 };
425
426 if has_md_source {
427 continue; }
429
430 let url_start = url_group.start();
434 let url_end = url_group.end();
435
436 warnings.push(LintWarning {
437 rule_name: Some(self.name().to_string()),
438 line: link.line,
439 column: url_start + 1, end_line: link.line,
441 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
443 severity: Severity::Error,
444 fix: None,
445 });
446 }
447 }
448 }
449 }
450
451 for image in &ctx.images {
453 let url = image.url.as_ref();
454
455 if url.is_empty() {
457 continue;
458 }
459
460 if self.is_external_url(url) || self.is_fragment_only_link(url) {
462 continue;
463 }
464
465 let file_path = Self::strip_query_and_fragment(url);
467
468 let decoded_path = Self::url_decode(file_path);
470
471 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
473
474 if file_exists_or_markdown_extension(&resolved_path) {
476 continue; }
478
479 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
481 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
482 && let (Some(stem), Some(parent)) = (
483 resolved_path.file_stem().and_then(|s| s.to_str()),
484 resolved_path.parent(),
485 ) {
486 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
487 let source_path = parent.join(format!("{stem}{md_ext}"));
488 file_exists_with_cache(&source_path)
489 })
490 } else {
491 false
492 };
493
494 if has_md_source {
495 continue; }
497
498 warnings.push(LintWarning {
501 rule_name: Some(self.name().to_string()),
502 line: image.line,
503 column: image.start_col + 1,
504 end_line: image.line,
505 end_column: image.start_col + 1 + url.len(),
506 message: format!("Relative link '{url}' does not exist"),
507 severity: Severity::Error,
508 fix: None,
509 });
510 }
511
512 Ok(warnings)
513 }
514
515 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
516 Ok(ctx.content.to_string())
517 }
518
519 fn as_any(&self) -> &dyn std::any::Any {
520 self
521 }
522
523 fn default_config_section(&self) -> Option<(String, toml::Value)> {
524 None
526 }
527
528 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
529 where
530 Self: Sized,
531 {
532 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
533 Box::new(Self::from_config_struct(rule_config))
534 }
535
536 fn cross_file_scope(&self) -> CrossFileScope {
537 CrossFileScope::Workspace
538 }
539
540 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
541 for link in extract_cross_file_links(ctx) {
544 index.add_cross_file_link(link);
545 }
546 }
547
548 fn cross_file_check(
549 &self,
550 file_path: &Path,
551 file_index: &FileIndex,
552 workspace_index: &crate::workspace_index::WorkspaceIndex,
553 ) -> LintResult {
554 let mut warnings = Vec::new();
555
556 let file_dir = file_path.parent();
558
559 for cross_link in &file_index.cross_file_links {
560 let decoded_target = Self::url_decode(&cross_link.target_path);
563
564 if decoded_target.starts_with('/') {
566 continue;
567 }
568
569 let target_path = if let Some(dir) = file_dir {
571 dir.join(&decoded_target)
572 } else {
573 Path::new(&decoded_target).to_path_buf()
574 };
575
576 let target_path = normalize_path(&target_path);
578
579 let file_exists = workspace_index.contains_file(&target_path) || target_path.exists();
581
582 if !file_exists {
583 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
586 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
587 && let (Some(stem), Some(parent)) =
588 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
589 {
590 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
591 let source_path = parent.join(format!("{stem}{md_ext}"));
592 workspace_index.contains_file(&source_path) || source_path.exists()
593 })
594 } else {
595 false
596 };
597
598 if !has_md_source {
599 warnings.push(LintWarning {
600 rule_name: Some(self.name().to_string()),
601 line: cross_link.line,
602 column: cross_link.column,
603 end_line: cross_link.line,
604 end_column: cross_link.column + cross_link.target_path.len(),
605 message: format!("Relative link '{}' does not exist", cross_link.target_path),
606 severity: Severity::Error,
607 fix: None,
608 });
609 }
610 }
611 }
612
613 Ok(warnings)
614 }
615}
616
617fn normalize_path(path: &Path) -> PathBuf {
619 let mut components = Vec::new();
620
621 for component in path.components() {
622 match component {
623 std::path::Component::ParentDir => {
624 if !components.is_empty() {
626 components.pop();
627 }
628 }
629 std::path::Component::CurDir => {
630 }
632 _ => {
633 components.push(component);
634 }
635 }
636 }
637
638 components.iter().collect()
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use crate::workspace_index::CrossFileLinkIndex;
645 use std::fs::File;
646 use std::io::Write;
647 use tempfile::tempdir;
648
649 #[test]
650 fn test_strip_query_and_fragment() {
651 assert_eq!(
653 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
654 "file.png"
655 );
656 assert_eq!(
657 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
658 "file.png"
659 );
660 assert_eq!(
661 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
662 "file.png"
663 );
664
665 assert_eq!(
667 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
668 "file.md"
669 );
670 assert_eq!(
671 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
672 "file.md"
673 );
674
675 assert_eq!(
677 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
678 "file.md"
679 );
680
681 assert_eq!(
683 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
684 "file.png"
685 );
686
687 assert_eq!(
689 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
690 "path/to/image.png"
691 );
692 assert_eq!(
693 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
694 "path/to/image.png"
695 );
696
697 assert_eq!(
699 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
700 "file.md"
701 );
702 }
703
704 #[test]
705 fn test_url_decode() {
706 assert_eq!(
708 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
709 "penguin with space.jpg"
710 );
711
712 assert_eq!(
714 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
715 "assets/my file name.png"
716 );
717
718 assert_eq!(
720 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
721 "hello world!.md"
722 );
723
724 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
726
727 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
729
730 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
732
733 assert_eq!(
735 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
736 "normal-file.md"
737 );
738
739 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
741
742 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
744
745 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
747
748 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
750
751 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
753
754 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
756
757 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
759
760 assert_eq!(
762 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
763 "path/to/file.md"
764 );
765
766 assert_eq!(
768 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
769 "hello world/foo bar.md"
770 );
771
772 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
774
775 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
777 }
778
779 #[test]
780 fn test_url_encoded_filenames() {
781 let temp_dir = tempdir().unwrap();
783 let base_path = temp_dir.path();
784
785 let file_with_spaces = base_path.join("penguin with space.jpg");
787 File::create(&file_with_spaces)
788 .unwrap()
789 .write_all(b"image data")
790 .unwrap();
791
792 let subdir = base_path.join("my images");
794 std::fs::create_dir(&subdir).unwrap();
795 let nested_file = subdir.join("photo 1.png");
796 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
797
798 let content = r#"
800# Test Document with URL-Encoded Links
801
802
803
804
805"#;
806
807 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
808
809 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
810 let result = rule.check(&ctx).unwrap();
811
812 assert_eq!(
814 result.len(),
815 1,
816 "Should only warn about missing%20file.jpg. Got: {result:?}"
817 );
818 assert!(
819 result[0].message.contains("missing%20file.jpg"),
820 "Warning should mention the URL-encoded filename"
821 );
822 }
823
824 #[test]
825 fn test_external_urls() {
826 let rule = MD057ExistingRelativeLinks::new();
827
828 assert!(rule.is_external_url("https://example.com"));
830 assert!(rule.is_external_url("http://example.com"));
831 assert!(rule.is_external_url("ftp://example.com"));
832 assert!(rule.is_external_url("www.example.com"));
833 assert!(rule.is_external_url("example.com"));
834
835 assert!(rule.is_external_url("file:///path/to/file"));
837 assert!(rule.is_external_url("smb://server/share"));
838 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
839 assert!(rule.is_external_url("mailto:user@example.com"));
840 assert!(rule.is_external_url("tel:+1234567890"));
841 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
842 assert!(rule.is_external_url("javascript:void(0)"));
843 assert!(rule.is_external_url("ssh://git@github.com/repo"));
844 assert!(rule.is_external_url("git://github.com/repo.git"));
845
846 assert!(rule.is_external_url("user@example.com"));
849 assert!(rule.is_external_url("steering@kubernetes.io"));
850 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
851 assert!(rule.is_external_url("user_name@sub.domain.com"));
852 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
853
854 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"));
865 assert!(rule.is_external_url("/blog/2024/release.html"));
866 assert!(rule.is_external_url("/react/hooks/use-state.html"));
867 assert!(rule.is_external_url("/pkg/runtime"));
868 assert!(rule.is_external_url("/doc/go1compat"));
869 assert!(rule.is_external_url("/index.html"));
870 assert!(rule.is_external_url("/assets/logo.png"));
871
872 assert!(rule.is_external_url("~/assets/image.png"));
875 assert!(rule.is_external_url("~/components/Button.vue"));
876 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
880 assert!(rule.is_external_url("@images/photo.jpg"));
881 assert!(rule.is_external_url("@assets/styles.css"));
882
883 assert!(!rule.is_external_url("./relative/path.md"));
885 assert!(!rule.is_external_url("relative/path.md"));
886 assert!(!rule.is_external_url("../parent/path.md"));
887 }
888
889 #[test]
890 fn test_framework_path_aliases() {
891 let temp_dir = tempdir().unwrap();
893 let base_path = temp_dir.path();
894
895 let content = r#"
897# Framework Path Aliases
898
899
900
901
902
903[Link](@/pages/about.md)
904
905This is a [real missing link](missing.md) that should be flagged.
906"#;
907
908 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
909
910 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
911 let result = rule.check(&ctx).unwrap();
912
913 assert_eq!(
915 result.len(),
916 1,
917 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
918 );
919 assert!(
920 result[0].message.contains("missing.md"),
921 "Warning should be for missing.md"
922 );
923 }
924
925 #[test]
926 fn test_url_decode_security_path_traversal() {
927 let temp_dir = tempdir().unwrap();
930 let base_path = temp_dir.path();
931
932 let file_in_base = base_path.join("safe.md");
934 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
935
936 let content = r#"
941[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
942[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
943[Safe link](safe.md)
944"#;
945
946 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
947
948 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949 let result = rule.check(&ctx).unwrap();
950
951 assert_eq!(
954 result.len(),
955 2,
956 "Should have warnings for traversal attempts. Got: {result:?}"
957 );
958 }
959
960 #[test]
961 fn test_url_encoded_utf8_filenames() {
962 let temp_dir = tempdir().unwrap();
964 let base_path = temp_dir.path();
965
966 let cafe_file = base_path.join("café.md");
968 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
969
970 let content = r#"
971[Café link](caf%C3%A9.md)
972[Missing unicode](r%C3%A9sum%C3%A9.md)
973"#;
974
975 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
976
977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
978 let result = rule.check(&ctx).unwrap();
979
980 assert_eq!(
982 result.len(),
983 1,
984 "Should only warn about missing résumé.md. Got: {result:?}"
985 );
986 assert!(
987 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
988 "Warning should mention the URL-encoded filename"
989 );
990 }
991
992 #[test]
993 fn test_url_encoded_emoji_filenames() {
994 let temp_dir = tempdir().unwrap();
997 let base_path = temp_dir.path();
998
999 let emoji_dir = base_path.join("👤 Personal");
1001 std::fs::create_dir(&emoji_dir).unwrap();
1002
1003 let file_path = emoji_dir.join("TV Shows.md");
1005 File::create(&file_path)
1006 .unwrap()
1007 .write_all(b"# TV Shows\n\nContent here.")
1008 .unwrap();
1009
1010 let content = r#"
1013# Test Document
1014
1015[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1016[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1017"#;
1018
1019 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1020
1021 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1022 let result = rule.check(&ctx).unwrap();
1023
1024 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1026 assert!(
1027 result[0].message.contains("Missing.md"),
1028 "Warning should be for Missing.md, got: {}",
1029 result[0].message
1030 );
1031 }
1032
1033 #[test]
1034 fn test_no_warnings_without_base_path() {
1035 let rule = MD057ExistingRelativeLinks::new();
1036 let content = "[Link](missing.md)";
1037
1038 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040 assert!(result.is_empty(), "Should have no warnings without base path");
1041 }
1042
1043 #[test]
1044 fn test_existing_and_missing_links() {
1045 let temp_dir = tempdir().unwrap();
1047 let base_path = temp_dir.path();
1048
1049 let exists_path = base_path.join("exists.md");
1051 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1052
1053 assert!(exists_path.exists(), "exists.md should exist for this test");
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[Media Link](image.jpg)
1064 "#;
1065
1066 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1068
1069 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1071 let result = rule.check(&ctx).unwrap();
1072
1073 assert_eq!(result.len(), 2);
1075 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1076 assert!(messages.iter().any(|m| m.contains("missing.md")));
1077 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1078 }
1079
1080 #[test]
1081 fn test_angle_bracket_links() {
1082 let temp_dir = tempdir().unwrap();
1084 let base_path = temp_dir.path();
1085
1086 let exists_path = base_path.join("exists.md");
1088 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1089
1090 let content = r#"
1092# Test Document
1093
1094[Valid Link](<exists.md>)
1095[Invalid Link](<missing.md>)
1096[External Link](<https://example.com>)
1097 "#;
1098
1099 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1101
1102 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1103 let result = rule.check(&ctx).unwrap();
1104
1105 assert_eq!(result.len(), 1, "Should have exactly one warning");
1107 assert!(
1108 result[0].message.contains("missing.md"),
1109 "Warning should mention missing.md"
1110 );
1111 }
1112
1113 #[test]
1114 fn test_angle_bracket_links_with_parens() {
1115 let temp_dir = tempdir().unwrap();
1117 let base_path = temp_dir.path();
1118
1119 let app_dir = base_path.join("app");
1121 std::fs::create_dir(&app_dir).unwrap();
1122 let upload_dir = app_dir.join("(upload)");
1123 std::fs::create_dir(&upload_dir).unwrap();
1124 let page_file = upload_dir.join("page.tsx");
1125 File::create(&page_file)
1126 .unwrap()
1127 .write_all(b"export default function Page() {}")
1128 .unwrap();
1129
1130 let content = r#"
1132# Test Document with Paths Containing Parens
1133
1134[Upload Page](<app/(upload)/page.tsx>)
1135[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1136[Missing](<app/(missing)/file.md>)
1137"#;
1138
1139 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1140
1141 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1142 let result = rule.check(&ctx).unwrap();
1143
1144 assert_eq!(
1146 result.len(),
1147 1,
1148 "Should have exactly one warning for missing file. Got: {result:?}"
1149 );
1150 assert!(
1151 result[0].message.contains("app/(missing)/file.md"),
1152 "Warning should mention app/(missing)/file.md"
1153 );
1154 }
1155
1156 #[test]
1157 fn test_all_file_types_checked() {
1158 let temp_dir = tempdir().unwrap();
1160 let base_path = temp_dir.path();
1161
1162 let content = r#"
1164[Image Link](image.jpg)
1165[Video Link](video.mp4)
1166[Markdown Link](document.md)
1167[PDF Link](file.pdf)
1168"#;
1169
1170 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1171
1172 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1173 let result = rule.check(&ctx).unwrap();
1174
1175 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1177 }
1178
1179 #[test]
1180 fn test_code_span_detection() {
1181 let rule = MD057ExistingRelativeLinks::new();
1182
1183 let temp_dir = tempdir().unwrap();
1185 let base_path = temp_dir.path();
1186
1187 let rule = rule.with_path(base_path);
1188
1189 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1191
1192 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1193 let result = rule.check(&ctx).unwrap();
1194
1195 assert_eq!(result.len(), 1, "Should only flag the real link");
1197 assert!(result[0].message.contains("nonexistent.md"));
1198 }
1199
1200 #[test]
1201 fn test_inline_code_spans() {
1202 let temp_dir = tempdir().unwrap();
1204 let base_path = temp_dir.path();
1205
1206 let content = r#"
1208# Test Document
1209
1210This is a normal link: [Link](missing.md)
1211
1212This is a code span with a link: `[Link](another-missing.md)`
1213
1214Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1215
1216 "#;
1217
1218 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1220
1221 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1223 let result = rule.check(&ctx).unwrap();
1224
1225 assert_eq!(result.len(), 1, "Should have exactly one warning");
1227 assert!(
1228 result[0].message.contains("missing.md"),
1229 "Warning should be for missing.md"
1230 );
1231 assert!(
1232 !result.iter().any(|w| w.message.contains("another-missing.md")),
1233 "Should not warn about link in code span"
1234 );
1235 assert!(
1236 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1237 "Should not warn about link in inline code"
1238 );
1239 }
1240
1241 #[test]
1242 fn test_extensionless_link_resolution() {
1243 let temp_dir = tempdir().unwrap();
1245 let base_path = temp_dir.path();
1246
1247 let page_path = base_path.join("page.md");
1249 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1250
1251 let content = r#"
1253# Test Document
1254
1255[Link without extension](page)
1256[Link with extension](page.md)
1257[Missing link](nonexistent)
1258"#;
1259
1260 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1261
1262 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1263 let result = rule.check(&ctx).unwrap();
1264
1265 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1268 assert!(
1269 result[0].message.contains("nonexistent"),
1270 "Warning should be for 'nonexistent' not 'page'"
1271 );
1272 }
1273
1274 #[test]
1276 fn test_cross_file_scope() {
1277 let rule = MD057ExistingRelativeLinks::new();
1278 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1279 }
1280
1281 #[test]
1282 fn test_contribute_to_index_extracts_markdown_links() {
1283 let rule = MD057ExistingRelativeLinks::new();
1284 let content = r#"
1285# Document
1286
1287[Link to docs](./docs/guide.md)
1288[Link with fragment](./other.md#section)
1289[External link](https://example.com)
1290[Image link](image.png)
1291[Media file](video.mp4)
1292"#;
1293
1294 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1295 let mut index = FileIndex::new();
1296 rule.contribute_to_index(&ctx, &mut index);
1297
1298 assert_eq!(index.cross_file_links.len(), 2);
1300
1301 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1303 assert_eq!(index.cross_file_links[0].fragment, "");
1304
1305 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1307 assert_eq!(index.cross_file_links[1].fragment, "section");
1308 }
1309
1310 #[test]
1311 fn test_contribute_to_index_skips_external_and_anchors() {
1312 let rule = MD057ExistingRelativeLinks::new();
1313 let content = r#"
1314# Document
1315
1316[External](https://example.com)
1317[Another external](http://example.org)
1318[Fragment only](#section)
1319[FTP link](ftp://files.example.com)
1320[Mail link](mailto:test@example.com)
1321[WWW link](www.example.com)
1322"#;
1323
1324 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1325 let mut index = FileIndex::new();
1326 rule.contribute_to_index(&ctx, &mut index);
1327
1328 assert_eq!(index.cross_file_links.len(), 0);
1330 }
1331
1332 #[test]
1333 fn test_cross_file_check_valid_link() {
1334 use crate::workspace_index::WorkspaceIndex;
1335
1336 let rule = MD057ExistingRelativeLinks::new();
1337
1338 let mut workspace_index = WorkspaceIndex::new();
1340 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1341
1342 let mut file_index = FileIndex::new();
1344 file_index.add_cross_file_link(CrossFileLinkIndex {
1345 target_path: "guide.md".to_string(),
1346 fragment: "".to_string(),
1347 line: 5,
1348 column: 1,
1349 });
1350
1351 let warnings = rule
1353 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1354 .unwrap();
1355
1356 assert!(warnings.is_empty());
1358 }
1359
1360 #[test]
1361 fn test_cross_file_check_missing_link() {
1362 use crate::workspace_index::WorkspaceIndex;
1363
1364 let rule = MD057ExistingRelativeLinks::new();
1365
1366 let workspace_index = WorkspaceIndex::new();
1368
1369 let mut file_index = FileIndex::new();
1371 file_index.add_cross_file_link(CrossFileLinkIndex {
1372 target_path: "missing.md".to_string(),
1373 fragment: "".to_string(),
1374 line: 5,
1375 column: 1,
1376 });
1377
1378 let warnings = rule
1380 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1381 .unwrap();
1382
1383 assert_eq!(warnings.len(), 1);
1385 assert!(warnings[0].message.contains("missing.md"));
1386 assert!(warnings[0].message.contains("does not exist"));
1387 }
1388
1389 #[test]
1390 fn test_cross_file_check_parent_path() {
1391 use crate::workspace_index::WorkspaceIndex;
1392
1393 let rule = MD057ExistingRelativeLinks::new();
1394
1395 let mut workspace_index = WorkspaceIndex::new();
1397 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1398
1399 let mut file_index = FileIndex::new();
1401 file_index.add_cross_file_link(CrossFileLinkIndex {
1402 target_path: "../readme.md".to_string(),
1403 fragment: "".to_string(),
1404 line: 5,
1405 column: 1,
1406 });
1407
1408 let warnings = rule
1410 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1411 .unwrap();
1412
1413 assert!(warnings.is_empty());
1415 }
1416
1417 #[test]
1418 fn test_cross_file_check_html_link_with_md_source() {
1419 use crate::workspace_index::WorkspaceIndex;
1422
1423 let rule = MD057ExistingRelativeLinks::new();
1424
1425 let mut workspace_index = WorkspaceIndex::new();
1427 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1428
1429 let mut file_index = FileIndex::new();
1431 file_index.add_cross_file_link(CrossFileLinkIndex {
1432 target_path: "guide.html".to_string(),
1433 fragment: "section".to_string(),
1434 line: 10,
1435 column: 5,
1436 });
1437
1438 let warnings = rule
1440 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1441 .unwrap();
1442
1443 assert!(
1445 warnings.is_empty(),
1446 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1447 );
1448 }
1449
1450 #[test]
1451 fn test_cross_file_check_html_link_without_source() {
1452 use crate::workspace_index::WorkspaceIndex;
1454
1455 let rule = MD057ExistingRelativeLinks::new();
1456
1457 let workspace_index = WorkspaceIndex::new();
1459
1460 let mut file_index = FileIndex::new();
1462 file_index.add_cross_file_link(CrossFileLinkIndex {
1463 target_path: "missing.html".to_string(),
1464 fragment: "".to_string(),
1465 line: 10,
1466 column: 5,
1467 });
1468
1469 let warnings = rule
1471 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1472 .unwrap();
1473
1474 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1476 assert!(warnings[0].message.contains("missing.html"));
1477 }
1478
1479 #[test]
1480 fn test_normalize_path_function() {
1481 assert_eq!(
1483 normalize_path(Path::new("docs/guide.md")),
1484 PathBuf::from("docs/guide.md")
1485 );
1486
1487 assert_eq!(
1489 normalize_path(Path::new("./docs/guide.md")),
1490 PathBuf::from("docs/guide.md")
1491 );
1492
1493 assert_eq!(
1495 normalize_path(Path::new("docs/sub/../guide.md")),
1496 PathBuf::from("docs/guide.md")
1497 );
1498
1499 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1501 }
1502
1503 #[test]
1504 fn test_html_link_with_md_source() {
1505 let temp_dir = tempdir().unwrap();
1507 let base_path = temp_dir.path();
1508
1509 let md_file = base_path.join("guide.md");
1511 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1512
1513 let content = r#"
1514[Read the guide](guide.html)
1515[Also here](getting-started.html)
1516"#;
1517
1518 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1519 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1520 let result = rule.check(&ctx).unwrap();
1521
1522 assert_eq!(
1524 result.len(),
1525 1,
1526 "Should only warn about missing source. Got: {result:?}"
1527 );
1528 assert!(result[0].message.contains("getting-started.html"));
1529 }
1530
1531 #[test]
1532 fn test_htm_link_with_md_source() {
1533 let temp_dir = tempdir().unwrap();
1535 let base_path = temp_dir.path();
1536
1537 let md_file = base_path.join("page.md");
1538 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1539
1540 let content = "[Page](page.htm)";
1541
1542 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1543 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544 let result = rule.check(&ctx).unwrap();
1545
1546 assert!(
1547 result.is_empty(),
1548 "Should not warn when .md source exists for .htm link"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_html_link_finds_various_markdown_extensions() {
1554 let temp_dir = tempdir().unwrap();
1556 let base_path = temp_dir.path();
1557
1558 File::create(base_path.join("doc.md")).unwrap();
1559 File::create(base_path.join("tutorial.mdx")).unwrap();
1560 File::create(base_path.join("guide.markdown")).unwrap();
1561
1562 let content = r#"
1563[Doc](doc.html)
1564[Tutorial](tutorial.html)
1565[Guide](guide.html)
1566"#;
1567
1568 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1569 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1570 let result = rule.check(&ctx).unwrap();
1571
1572 assert!(
1573 result.is_empty(),
1574 "Should find all markdown variants as source files. Got: {result:?}"
1575 );
1576 }
1577
1578 #[test]
1579 fn test_html_link_in_subdirectory() {
1580 let temp_dir = tempdir().unwrap();
1582 let base_path = temp_dir.path();
1583
1584 let docs_dir = base_path.join("docs");
1585 std::fs::create_dir(&docs_dir).unwrap();
1586 File::create(docs_dir.join("guide.md"))
1587 .unwrap()
1588 .write_all(b"# Guide")
1589 .unwrap();
1590
1591 let content = "[Guide](docs/guide.html)";
1592
1593 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1594 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595 let result = rule.check(&ctx).unwrap();
1596
1597 assert!(result.is_empty(), "Should find markdown source in subdirectory");
1598 }
1599
1600 #[test]
1601 fn test_absolute_path_skipped_in_check() {
1602 let temp_dir = tempdir().unwrap();
1605 let base_path = temp_dir.path();
1606
1607 let content = r#"
1608# Test Document
1609
1610[Go Runtime](/pkg/runtime)
1611[Go Runtime with Fragment](/pkg/runtime#section)
1612[API Docs](/api/v1/users)
1613[Blog Post](/blog/2024/release.html)
1614[React Hook](/react/hooks/use-state.html)
1615"#;
1616
1617 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619 let result = rule.check(&ctx).unwrap();
1620
1621 assert!(
1623 result.is_empty(),
1624 "Absolute paths should be skipped. Got warnings: {result:?}"
1625 );
1626 }
1627
1628 #[test]
1629 fn test_absolute_path_skipped_in_cross_file_check() {
1630 use crate::workspace_index::WorkspaceIndex;
1632
1633 let rule = MD057ExistingRelativeLinks::new();
1634
1635 let workspace_index = WorkspaceIndex::new();
1637
1638 let mut file_index = FileIndex::new();
1640 file_index.add_cross_file_link(CrossFileLinkIndex {
1641 target_path: "/pkg/runtime.md".to_string(),
1642 fragment: "".to_string(),
1643 line: 5,
1644 column: 1,
1645 });
1646 file_index.add_cross_file_link(CrossFileLinkIndex {
1647 target_path: "/api/v1/users.md".to_string(),
1648 fragment: "section".to_string(),
1649 line: 10,
1650 column: 1,
1651 });
1652
1653 let warnings = rule
1655 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1656 .unwrap();
1657
1658 assert!(
1660 warnings.is_empty(),
1661 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1662 );
1663 }
1664
1665 #[test]
1666 fn test_protocol_relative_url_not_skipped() {
1667 let temp_dir = tempdir().unwrap();
1670 let base_path = temp_dir.path();
1671
1672 let content = r#"
1673# Test Document
1674
1675[External](//example.com/page)
1676[Another](//cdn.example.com/asset.js)
1677"#;
1678
1679 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1680 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681 let result = rule.check(&ctx).unwrap();
1682
1683 assert!(
1685 result.is_empty(),
1686 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
1687 );
1688 }
1689
1690 #[test]
1691 fn test_email_addresses_skipped() {
1692 let temp_dir = tempdir().unwrap();
1695 let base_path = temp_dir.path();
1696
1697 let content = r#"
1698# Test Document
1699
1700[Contact](user@example.com)
1701[Steering](steering@kubernetes.io)
1702[Support](john.doe+filter@company.co.uk)
1703[User](user_name@sub.domain.com)
1704"#;
1705
1706 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1707 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1708 let result = rule.check(&ctx).unwrap();
1709
1710 assert!(
1712 result.is_empty(),
1713 "Email addresses should be skipped. Got warnings: {result:?}"
1714 );
1715 }
1716
1717 #[test]
1718 fn test_email_addresses_vs_file_paths() {
1719 let temp_dir = tempdir().unwrap();
1722 let base_path = temp_dir.path();
1723
1724 let content = r#"
1725# Test Document
1726
1727[Email](user@example.com) <!-- Should be skipped (email) -->
1728[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
1729[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
1730"#;
1731
1732 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1733 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1734 let result = rule.check(&ctx).unwrap();
1735
1736 assert!(
1738 result.is_empty(),
1739 "All email addresses should be skipped. Got: {result:?}"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_diagnostic_position_accuracy() {
1745 let temp_dir = tempdir().unwrap();
1747 let base_path = temp_dir.path();
1748
1749 let content = "prefix [text](missing.md) suffix";
1752 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1757 let result = rule.check(&ctx).unwrap();
1758
1759 assert_eq!(result.len(), 1, "Should have exactly one warning");
1760 assert_eq!(result[0].line, 1, "Should be on line 1");
1761 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
1762 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
1763 }
1764
1765 #[test]
1766 fn test_diagnostic_position_angle_brackets() {
1767 let temp_dir = tempdir().unwrap();
1769 let base_path = temp_dir.path();
1770
1771 let content = "[link](<missing.md>)";
1774 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1777 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1778 let result = rule.check(&ctx).unwrap();
1779
1780 assert_eq!(result.len(), 1, "Should have exactly one warning");
1781 assert_eq!(result[0].line, 1, "Should be on line 1");
1782 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
1783 }
1784
1785 #[test]
1786 fn test_diagnostic_position_multiline() {
1787 let temp_dir = tempdir().unwrap();
1789 let base_path = temp_dir.path();
1790
1791 let content = r#"# Title
1792Some text on line 2
1793[link on line 3](missing1.md)
1794More text
1795[link on line 5](missing2.md)"#;
1796
1797 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1798 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1799 let result = rule.check(&ctx).unwrap();
1800
1801 assert_eq!(result.len(), 2, "Should have two warnings");
1802
1803 assert_eq!(result[0].line, 3, "First warning should be on line 3");
1805 assert!(result[0].message.contains("missing1.md"));
1806
1807 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
1809 assert!(result[1].message.contains("missing2.md"));
1810 }
1811
1812 #[test]
1813 fn test_diagnostic_position_with_spaces() {
1814 let temp_dir = tempdir().unwrap();
1816 let base_path = temp_dir.path();
1817
1818 let content = "[link]( missing.md )";
1819 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825 let result = rule.check(&ctx).unwrap();
1826
1827 assert_eq!(result.len(), 1, "Should have exactly one warning");
1828 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
1830 }
1831
1832 #[test]
1833 fn test_diagnostic_position_image() {
1834 let temp_dir = tempdir().unwrap();
1836 let base_path = temp_dir.path();
1837
1838 let content = "";
1839
1840 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 for image");
1845 assert_eq!(result[0].line, 1);
1846 assert!(result[0].column > 0, "Should have valid column position");
1848 assert!(result[0].message.contains("missing.jpg"));
1849 }
1850
1851 #[test]
1852 fn test_wikilinks_skipped() {
1853 let temp_dir = tempdir().unwrap();
1856 let base_path = temp_dir.path();
1857
1858 let content = r#"# Test Document
1859
1860[[Microsoft#Windows OS]]
1861[[SomePage]]
1862[[Page With Spaces]]
1863[[path/to/page#section]]
1864[[page|Display Text]]
1865
1866This is a [real missing link](missing.md) that should be flagged.
1867"#;
1868
1869 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1870 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1871 let result = rule.check(&ctx).unwrap();
1872
1873 assert_eq!(
1875 result.len(),
1876 1,
1877 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
1878 );
1879 assert!(
1880 result[0].message.contains("missing.md"),
1881 "Warning should be for missing.md, not wikilinks"
1882 );
1883 }
1884
1885 #[test]
1886 fn test_wikilinks_not_added_to_index() {
1887 let temp_dir = tempdir().unwrap();
1889 let base_path = temp_dir.path();
1890
1891 let content = r#"# Test Document
1892
1893[[Microsoft#Windows OS]]
1894[[SomePage#section]]
1895[Regular Link](other.md)
1896"#;
1897
1898 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1899 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1900
1901 let mut file_index = FileIndex::new();
1902 rule.contribute_to_index(&ctx, &mut file_index);
1903
1904 let cross_file_links = &file_index.cross_file_links;
1907 assert_eq!(
1908 cross_file_links.len(),
1909 1,
1910 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1911 );
1912 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1913 }
1914}