1use crate::rule::{CrossFileScope, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
7use crate::utils::element_cache::ElementCache;
8use crate::workspace_index::{CrossFileLinkIndex, FileIndex};
9use regex::Regex;
10use std::collections::HashMap;
11use std::env;
12use std::path::{Path, PathBuf};
13use std::sync::LazyLock;
14use std::sync::{Arc, Mutex};
15
16mod md057_config;
17use md057_config::MD057Config;
18
19static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
21 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
22
23fn reset_file_existence_cache() {
25 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
26 cache.clear();
27 }
28}
29
30fn file_exists_with_cache(path: &Path) -> bool {
32 match FILE_EXISTENCE_CACHE.lock() {
33 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
34 Err(_) => path.exists(), }
36}
37
38fn file_exists_or_markdown_extension(path: &Path) -> bool {
41 if file_exists_with_cache(path) {
43 return true;
44 }
45
46 if path.extension().is_none() {
48 for ext in MARKDOWN_EXTENSIONS {
49 let path_with_ext = path.with_extension(&ext[1..]);
51 if file_exists_with_cache(&path_with_ext) {
52 return true;
53 }
54 }
55 }
56
57 false
58}
59
60static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
62
63static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
67 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
68
69static URL_EXTRACT_REGEX: LazyLock<Regex> =
72 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
73
74static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
78 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
79
80static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
82
83#[inline]
86fn hex_digit_to_value(byte: u8) -> Option<u8> {
87 match byte {
88 b'0'..=b'9' => Some(byte - b'0'),
89 b'a'..=b'f' => Some(byte - b'a' + 10),
90 b'A'..=b'F' => Some(byte - b'A' + 10),
91 _ => None,
92 }
93}
94
95const MARKDOWN_EXTENSIONS: &[&str] = &[
97 ".md",
98 ".markdown",
99 ".mdx",
100 ".mkd",
101 ".mkdn",
102 ".mdown",
103 ".mdwn",
104 ".qmd",
105 ".rmd",
106];
107
108#[inline]
110fn is_markdown_file(path: &str) -> bool {
111 let path_lower = path.to_lowercase();
112 MARKDOWN_EXTENSIONS.iter().any(|ext| path_lower.ends_with(ext))
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct MD057ExistingRelativeLinks {
118 base_path: Arc<Mutex<Option<PathBuf>>>,
120}
121
122impl MD057ExistingRelativeLinks {
123 pub fn new() -> Self {
125 Self::default()
126 }
127
128 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
130 let path = path.as_ref();
131 let dir_path = if path.is_file() {
132 path.parent().map(|p| p.to_path_buf())
133 } else {
134 Some(path.to_path_buf())
135 };
136
137 if let Ok(mut guard) = self.base_path.lock() {
138 *guard = dir_path;
139 }
140 self
141 }
142
143 #[allow(unused_variables)]
144 pub fn from_config_struct(config: MD057Config) -> Self {
145 Self::default()
146 }
147
148 #[inline]
160 fn is_external_url(&self, url: &str) -> bool {
161 if url.is_empty() {
162 return false;
163 }
164
165 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
167 return true;
168 }
169
170 if url.starts_with("{{") || url.starts_with("{%") {
173 return true;
174 }
175
176 if url.contains('@') {
179 return true; }
181
182 if url.ends_with(".com") {
189 return true;
190 }
191
192 if url.starts_with('/') {
196 return true;
197 }
198
199 if url.starts_with('~') || url.starts_with('@') {
203 return true;
204 }
205
206 false
208 }
209
210 #[inline]
212 fn is_fragment_only_link(&self, url: &str) -> bool {
213 url.starts_with('#')
214 }
215
216 fn url_decode(path: &str) -> String {
220 if !path.contains('%') {
222 return path.to_string();
223 }
224
225 let bytes = path.as_bytes();
226 let mut result = Vec::with_capacity(bytes.len());
227 let mut i = 0;
228
229 while i < bytes.len() {
230 if bytes[i] == b'%' && i + 2 < bytes.len() {
231 let hex1 = bytes[i + 1];
233 let hex2 = bytes[i + 2];
234 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
235 result.push(d1 * 16 + d2);
236 i += 3;
237 continue;
238 }
239 }
240 result.push(bytes[i]);
241 i += 1;
242 }
243
244 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
246 }
247
248 fn strip_query_and_fragment(url: &str) -> &str {
256 let query_pos = url.find('?');
259 let fragment_pos = url.find('#');
260
261 match (query_pos, fragment_pos) {
262 (Some(q), Some(f)) => {
263 &url[..q.min(f)]
265 }
266 (Some(q), None) => &url[..q],
267 (None, Some(f)) => &url[..f],
268 (None, None) => url,
269 }
270 }
271
272 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
274 base_path.join(link)
275 }
276}
277
278impl Rule for MD057ExistingRelativeLinks {
279 fn name(&self) -> &'static str {
280 "MD057"
281 }
282
283 fn description(&self) -> &'static str {
284 "Relative links should point to existing files"
285 }
286
287 fn category(&self) -> RuleCategory {
288 RuleCategory::Link
289 }
290
291 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
292 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
293 }
294
295 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
296 let content = ctx.content;
297
298 if content.is_empty() || !content.contains('[') {
300 return Ok(Vec::new());
301 }
302
303 if !content.contains("](") {
305 return Ok(Vec::new());
306 }
307
308 reset_file_existence_cache();
310
311 let mut warnings = Vec::new();
312
313 let base_path: Option<PathBuf> = {
317 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
319 if explicit_base.is_some() {
320 explicit_base
321 } else if let Some(ref source_file) = ctx.source_file {
322 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
326 resolved_file
327 .parent()
328 .map(|p| p.to_path_buf())
329 .or_else(|| Some(CURRENT_DIR.clone()))
330 } else {
331 None
333 }
334 };
335
336 let Some(base_path) = base_path else {
338 return Ok(warnings);
339 };
340
341 if !ctx.links.is_empty() {
343 let line_index = &ctx.line_index;
345
346 let element_cache = ElementCache::new(content);
348
349 let lines: Vec<&str> = content.lines().collect();
351
352 for link in &ctx.links {
353 let line_idx = link.line - 1;
354 if line_idx >= lines.len() {
355 continue;
356 }
357
358 let line = lines[line_idx];
359
360 if !line.contains("](") {
362 continue;
363 }
364
365 for link_match in LINK_START_REGEX.find_iter(line) {
367 let start_pos = link_match.start();
368 let end_pos = link_match.end();
369
370 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
372 let absolute_start_pos = line_start_byte + start_pos;
373
374 if element_cache.is_in_code_span(absolute_start_pos) {
376 continue;
377 }
378
379 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
383 .captures_at(line, end_pos - 1)
384 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
385 .or_else(|| {
386 URL_EXTRACT_REGEX
387 .captures_at(line, end_pos - 1)
388 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
389 });
390
391 if let Some((_caps, url_group)) = caps_and_url {
392 let url = url_group.as_str().trim();
393
394 if url.is_empty() {
396 continue;
397 }
398
399 if self.is_external_url(url) || self.is_fragment_only_link(url) {
401 continue;
402 }
403
404 let file_path = Self::strip_query_and_fragment(url);
406
407 let decoded_path = Self::url_decode(file_path);
409
410 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
412
413 if file_exists_or_markdown_extension(&resolved_path) {
415 continue; }
417
418 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
420 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
421 && let (Some(stem), Some(parent)) = (
422 resolved_path.file_stem().and_then(|s| s.to_str()),
423 resolved_path.parent(),
424 ) {
425 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
426 let source_path = parent.join(format!("{stem}{md_ext}"));
427 file_exists_with_cache(&source_path)
428 })
429 } else {
430 false
431 };
432
433 if has_md_source {
434 continue; }
436
437 let url_start = url_group.start();
441 let url_end = url_group.end();
442
443 warnings.push(LintWarning {
444 rule_name: Some(self.name().to_string()),
445 line: link.line,
446 column: url_start + 1, end_line: link.line,
448 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
450 severity: Severity::Error,
451 fix: None,
452 });
453 }
454 }
455 }
456 }
457
458 for image in &ctx.images {
460 let url = image.url.as_ref();
461
462 if url.is_empty() {
464 continue;
465 }
466
467 if self.is_external_url(url) || self.is_fragment_only_link(url) {
469 continue;
470 }
471
472 let file_path = Self::strip_query_and_fragment(url);
474
475 let decoded_path = Self::url_decode(file_path);
477
478 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
480
481 if file_exists_or_markdown_extension(&resolved_path) {
483 continue; }
485
486 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
488 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
489 && let (Some(stem), Some(parent)) = (
490 resolved_path.file_stem().and_then(|s| s.to_str()),
491 resolved_path.parent(),
492 ) {
493 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
494 let source_path = parent.join(format!("{stem}{md_ext}"));
495 file_exists_with_cache(&source_path)
496 })
497 } else {
498 false
499 };
500
501 if has_md_source {
502 continue; }
504
505 warnings.push(LintWarning {
508 rule_name: Some(self.name().to_string()),
509 line: image.line,
510 column: image.start_col + 1,
511 end_line: image.line,
512 end_column: image.start_col + 1 + url.len(),
513 message: format!("Relative link '{url}' does not exist"),
514 severity: Severity::Error,
515 fix: None,
516 });
517 }
518
519 Ok(warnings)
520 }
521
522 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
523 Ok(ctx.content.to_string())
524 }
525
526 fn as_any(&self) -> &dyn std::any::Any {
527 self
528 }
529
530 fn default_config_section(&self) -> Option<(String, toml::Value)> {
531 None
533 }
534
535 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
536 where
537 Self: Sized,
538 {
539 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
540 Box::new(Self::from_config_struct(rule_config))
541 }
542
543 fn cross_file_scope(&self) -> CrossFileScope {
544 CrossFileScope::Workspace
545 }
546
547 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
548 let content = ctx.content;
549
550 if content.is_empty() || !content.contains("](") {
552 return;
553 }
554
555 let lines: Vec<&str> = content.lines().collect();
557 let element_cache = ElementCache::new(content);
558 let line_index = &ctx.line_index;
559
560 for link in &ctx.links {
561 let line_idx = link.line - 1;
562 if line_idx >= lines.len() {
563 continue;
564 }
565
566 let line = lines[line_idx];
567 if !line.contains("](") {
568 continue;
569 }
570
571 for link_match in LINK_START_REGEX.find_iter(line) {
573 let start_pos = link_match.start();
574 let end_pos = link_match.end();
575
576 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
578 let absolute_start_pos = line_start_byte + start_pos;
579
580 if element_cache.is_in_code_span(absolute_start_pos) {
582 continue;
583 }
584
585 let caps_result = URL_EXTRACT_ANGLE_BRACKET_REGEX
589 .captures_at(line, end_pos - 1)
590 .or_else(|| URL_EXTRACT_REGEX.captures_at(line, end_pos - 1));
591
592 if let Some(caps) = caps_result
593 && let Some(url_group) = caps.get(1)
594 {
595 let file_path = url_group.as_str().trim();
596
597 if file_path.is_empty()
600 || PROTOCOL_DOMAIN_REGEX.is_match(file_path)
601 || file_path.starts_with("www.")
602 || file_path.starts_with('#')
603 || file_path.starts_with("{{")
604 || file_path.starts_with("{%")
605 || file_path.starts_with('/')
606 || file_path.starts_with('~')
607 || file_path.starts_with('@')
608 {
609 continue;
610 }
611
612 let file_path = Self::strip_query_and_fragment(file_path);
614
615 let fragment = caps.get(2).map(|m| m.as_str().trim_start_matches('#')).unwrap_or("");
617
618 if is_markdown_file(file_path) {
621 index.add_cross_file_link(CrossFileLinkIndex {
622 target_path: file_path.to_string(),
623 fragment: fragment.to_string(),
624 line: link.line,
625 column: start_pos + 1,
626 });
627 }
628 }
629 }
630 }
631 }
632
633 fn cross_file_check(
634 &self,
635 file_path: &Path,
636 file_index: &FileIndex,
637 workspace_index: &crate::workspace_index::WorkspaceIndex,
638 ) -> LintResult {
639 let mut warnings = Vec::new();
640
641 let file_dir = file_path.parent();
643
644 for cross_link in &file_index.cross_file_links {
645 let decoded_target = Self::url_decode(&cross_link.target_path);
648
649 if decoded_target.starts_with('/') {
651 continue;
652 }
653
654 let target_path = if let Some(dir) = file_dir {
656 dir.join(&decoded_target)
657 } else {
658 Path::new(&decoded_target).to_path_buf()
659 };
660
661 let target_path = normalize_path(&target_path);
663
664 let file_exists = workspace_index.contains_file(&target_path) || target_path.exists();
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 std::fs::File;
730 use std::io::Write;
731 use tempfile::tempdir;
732
733 #[test]
734 fn test_strip_query_and_fragment() {
735 assert_eq!(
737 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
738 "file.png"
739 );
740 assert_eq!(
741 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
742 "file.png"
743 );
744 assert_eq!(
745 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
746 "file.png"
747 );
748
749 assert_eq!(
751 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
752 "file.md"
753 );
754 assert_eq!(
755 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
756 "file.md"
757 );
758
759 assert_eq!(
761 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
762 "file.md"
763 );
764
765 assert_eq!(
767 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
768 "file.png"
769 );
770
771 assert_eq!(
773 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
774 "path/to/image.png"
775 );
776 assert_eq!(
777 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
778 "path/to/image.png"
779 );
780
781 assert_eq!(
783 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
784 "file.md"
785 );
786 }
787
788 #[test]
789 fn test_url_decode() {
790 assert_eq!(
792 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
793 "penguin with space.jpg"
794 );
795
796 assert_eq!(
798 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
799 "assets/my file name.png"
800 );
801
802 assert_eq!(
804 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
805 "hello world!.md"
806 );
807
808 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
810
811 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
813
814 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
816
817 assert_eq!(
819 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
820 "normal-file.md"
821 );
822
823 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
825
826 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
828
829 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
831
832 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
834
835 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
837
838 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
840
841 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
843
844 assert_eq!(
846 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
847 "path/to/file.md"
848 );
849
850 assert_eq!(
852 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
853 "hello world/foo bar.md"
854 );
855
856 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
858
859 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
861 }
862
863 #[test]
864 fn test_url_encoded_filenames() {
865 let temp_dir = tempdir().unwrap();
867 let base_path = temp_dir.path();
868
869 let file_with_spaces = base_path.join("penguin with space.jpg");
871 File::create(&file_with_spaces)
872 .unwrap()
873 .write_all(b"image data")
874 .unwrap();
875
876 let subdir = base_path.join("my images");
878 std::fs::create_dir(&subdir).unwrap();
879 let nested_file = subdir.join("photo 1.png");
880 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
881
882 let content = r#"
884# Test Document with URL-Encoded Links
885
886
887
888
889"#;
890
891 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
892
893 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
894 let result = rule.check(&ctx).unwrap();
895
896 assert_eq!(
898 result.len(),
899 1,
900 "Should only warn about missing%20file.jpg. Got: {result:?}"
901 );
902 assert!(
903 result[0].message.contains("missing%20file.jpg"),
904 "Warning should mention the URL-encoded filename"
905 );
906 }
907
908 #[test]
909 fn test_external_urls() {
910 let rule = MD057ExistingRelativeLinks::new();
911
912 assert!(rule.is_external_url("https://example.com"));
914 assert!(rule.is_external_url("http://example.com"));
915 assert!(rule.is_external_url("ftp://example.com"));
916 assert!(rule.is_external_url("www.example.com"));
917 assert!(rule.is_external_url("example.com"));
918
919 assert!(rule.is_external_url("file:///path/to/file"));
921 assert!(rule.is_external_url("smb://server/share"));
922 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
923 assert!(rule.is_external_url("mailto:user@example.com"));
924 assert!(rule.is_external_url("tel:+1234567890"));
925 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
926 assert!(rule.is_external_url("javascript:void(0)"));
927 assert!(rule.is_external_url("ssh://git@github.com/repo"));
928 assert!(rule.is_external_url("git://github.com/repo.git"));
929
930 assert!(rule.is_external_url("user@example.com"));
933 assert!(rule.is_external_url("steering@kubernetes.io"));
934 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
935 assert!(rule.is_external_url("user_name@sub.domain.com"));
936 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
937
938 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"));
949 assert!(rule.is_external_url("/blog/2024/release.html"));
950 assert!(rule.is_external_url("/react/hooks/use-state.html"));
951 assert!(rule.is_external_url("/pkg/runtime"));
952 assert!(rule.is_external_url("/doc/go1compat"));
953 assert!(rule.is_external_url("/index.html"));
954 assert!(rule.is_external_url("/assets/logo.png"));
955
956 assert!(rule.is_external_url("~/assets/image.png"));
959 assert!(rule.is_external_url("~/components/Button.vue"));
960 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
964 assert!(rule.is_external_url("@images/photo.jpg"));
965 assert!(rule.is_external_url("@assets/styles.css"));
966
967 assert!(!rule.is_external_url("./relative/path.md"));
969 assert!(!rule.is_external_url("relative/path.md"));
970 assert!(!rule.is_external_url("../parent/path.md"));
971 }
972
973 #[test]
974 fn test_framework_path_aliases() {
975 let temp_dir = tempdir().unwrap();
977 let base_path = temp_dir.path();
978
979 let content = r#"
981# Framework Path Aliases
982
983
984
985
986
987[Link](@/pages/about.md)
988
989This is a [real missing link](missing.md) that should be flagged.
990"#;
991
992 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
993
994 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995 let result = rule.check(&ctx).unwrap();
996
997 assert_eq!(
999 result.len(),
1000 1,
1001 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1002 );
1003 assert!(
1004 result[0].message.contains("missing.md"),
1005 "Warning should be for missing.md"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_url_decode_security_path_traversal() {
1011 let temp_dir = tempdir().unwrap();
1014 let base_path = temp_dir.path();
1015
1016 let file_in_base = base_path.join("safe.md");
1018 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1019
1020 let content = r#"
1025[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1026[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1027[Safe link](safe.md)
1028"#;
1029
1030 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1031
1032 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1033 let result = rule.check(&ctx).unwrap();
1034
1035 assert_eq!(
1038 result.len(),
1039 2,
1040 "Should have warnings for traversal attempts. Got: {result:?}"
1041 );
1042 }
1043
1044 #[test]
1045 fn test_url_encoded_utf8_filenames() {
1046 let temp_dir = tempdir().unwrap();
1048 let base_path = temp_dir.path();
1049
1050 let cafe_file = base_path.join("café.md");
1052 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1053
1054 let content = r#"
1055[Café link](caf%C3%A9.md)
1056[Missing unicode](r%C3%A9sum%C3%A9.md)
1057"#;
1058
1059 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1060
1061 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1062 let result = rule.check(&ctx).unwrap();
1063
1064 assert_eq!(
1066 result.len(),
1067 1,
1068 "Should only warn about missing résumé.md. Got: {result:?}"
1069 );
1070 assert!(
1071 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1072 "Warning should mention the URL-encoded filename"
1073 );
1074 }
1075
1076 #[test]
1077 fn test_url_encoded_emoji_filenames() {
1078 let temp_dir = tempdir().unwrap();
1081 let base_path = temp_dir.path();
1082
1083 let emoji_dir = base_path.join("👤 Personal");
1085 std::fs::create_dir(&emoji_dir).unwrap();
1086
1087 let file_path = emoji_dir.join("TV Shows.md");
1089 File::create(&file_path)
1090 .unwrap()
1091 .write_all(b"# TV Shows\n\nContent here.")
1092 .unwrap();
1093
1094 let content = r#"
1097# Test Document
1098
1099[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1100[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1101"#;
1102
1103 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1104
1105 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1106 let result = rule.check(&ctx).unwrap();
1107
1108 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1110 assert!(
1111 result[0].message.contains("Missing.md"),
1112 "Warning should be for Missing.md, got: {}",
1113 result[0].message
1114 );
1115 }
1116
1117 #[test]
1118 fn test_no_warnings_without_base_path() {
1119 let rule = MD057ExistingRelativeLinks::new();
1120 let content = "[Link](missing.md)";
1121
1122 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1123 let result = rule.check(&ctx).unwrap();
1124 assert!(result.is_empty(), "Should have no warnings without base path");
1125 }
1126
1127 #[test]
1128 fn test_existing_and_missing_links() {
1129 let temp_dir = tempdir().unwrap();
1131 let base_path = temp_dir.path();
1132
1133 let exists_path = base_path.join("exists.md");
1135 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1136
1137 assert!(exists_path.exists(), "exists.md should exist for this test");
1139
1140 let content = r#"
1142# Test Document
1143
1144[Valid Link](exists.md)
1145[Invalid Link](missing.md)
1146[External Link](https://example.com)
1147[Media Link](image.jpg)
1148 "#;
1149
1150 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1152
1153 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1155 let result = rule.check(&ctx).unwrap();
1156
1157 assert_eq!(result.len(), 2);
1159 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1160 assert!(messages.iter().any(|m| m.contains("missing.md")));
1161 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1162 }
1163
1164 #[test]
1165 fn test_angle_bracket_links() {
1166 let temp_dir = tempdir().unwrap();
1168 let base_path = temp_dir.path();
1169
1170 let exists_path = base_path.join("exists.md");
1172 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1173
1174 let content = r#"
1176# Test Document
1177
1178[Valid Link](<exists.md>)
1179[Invalid Link](<missing.md>)
1180[External Link](<https://example.com>)
1181 "#;
1182
1183 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1185
1186 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1187 let result = rule.check(&ctx).unwrap();
1188
1189 assert_eq!(result.len(), 1, "Should have exactly one warning");
1191 assert!(
1192 result[0].message.contains("missing.md"),
1193 "Warning should mention missing.md"
1194 );
1195 }
1196
1197 #[test]
1198 fn test_angle_bracket_links_with_parens() {
1199 let temp_dir = tempdir().unwrap();
1201 let base_path = temp_dir.path();
1202
1203 let app_dir = base_path.join("app");
1205 std::fs::create_dir(&app_dir).unwrap();
1206 let upload_dir = app_dir.join("(upload)");
1207 std::fs::create_dir(&upload_dir).unwrap();
1208 let page_file = upload_dir.join("page.tsx");
1209 File::create(&page_file)
1210 .unwrap()
1211 .write_all(b"export default function Page() {}")
1212 .unwrap();
1213
1214 let content = r#"
1216# Test Document with Paths Containing Parens
1217
1218[Upload Page](<app/(upload)/page.tsx>)
1219[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1220[Missing](<app/(missing)/file.md>)
1221"#;
1222
1223 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1224
1225 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1226 let result = rule.check(&ctx).unwrap();
1227
1228 assert_eq!(
1230 result.len(),
1231 1,
1232 "Should have exactly one warning for missing file. Got: {result:?}"
1233 );
1234 assert!(
1235 result[0].message.contains("app/(missing)/file.md"),
1236 "Warning should mention app/(missing)/file.md"
1237 );
1238 }
1239
1240 #[test]
1241 fn test_all_file_types_checked() {
1242 let temp_dir = tempdir().unwrap();
1244 let base_path = temp_dir.path();
1245
1246 let content = r#"
1248[Image Link](image.jpg)
1249[Video Link](video.mp4)
1250[Markdown Link](document.md)
1251[PDF Link](file.pdf)
1252"#;
1253
1254 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1255
1256 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1257 let result = rule.check(&ctx).unwrap();
1258
1259 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1261 }
1262
1263 #[test]
1264 fn test_code_span_detection() {
1265 let rule = MD057ExistingRelativeLinks::new();
1266
1267 let temp_dir = tempdir().unwrap();
1269 let base_path = temp_dir.path();
1270
1271 let rule = rule.with_path(base_path);
1272
1273 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1275
1276 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277 let result = rule.check(&ctx).unwrap();
1278
1279 assert_eq!(result.len(), 1, "Should only flag the real link");
1281 assert!(result[0].message.contains("nonexistent.md"));
1282 }
1283
1284 #[test]
1285 fn test_inline_code_spans() {
1286 let temp_dir = tempdir().unwrap();
1288 let base_path = temp_dir.path();
1289
1290 let content = r#"
1292# Test Document
1293
1294This is a normal link: [Link](missing.md)
1295
1296This is a code span with a link: `[Link](another-missing.md)`
1297
1298Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1299
1300 "#;
1301
1302 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1304
1305 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1307 let result = rule.check(&ctx).unwrap();
1308
1309 assert_eq!(result.len(), 1, "Should have exactly one warning");
1311 assert!(
1312 result[0].message.contains("missing.md"),
1313 "Warning should be for missing.md"
1314 );
1315 assert!(
1316 !result.iter().any(|w| w.message.contains("another-missing.md")),
1317 "Should not warn about link in code span"
1318 );
1319 assert!(
1320 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1321 "Should not warn about link in inline code"
1322 );
1323 }
1324
1325 #[test]
1326 fn test_extensionless_link_resolution() {
1327 let temp_dir = tempdir().unwrap();
1329 let base_path = temp_dir.path();
1330
1331 let page_path = base_path.join("page.md");
1333 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1334
1335 let content = r#"
1337# Test Document
1338
1339[Link without extension](page)
1340[Link with extension](page.md)
1341[Missing link](nonexistent)
1342"#;
1343
1344 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1345
1346 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1347 let result = rule.check(&ctx).unwrap();
1348
1349 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1352 assert!(
1353 result[0].message.contains("nonexistent"),
1354 "Warning should be for 'nonexistent' not 'page'"
1355 );
1356 }
1357
1358 #[test]
1360 fn test_cross_file_scope() {
1361 let rule = MD057ExistingRelativeLinks::new();
1362 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1363 }
1364
1365 #[test]
1366 fn test_contribute_to_index_extracts_markdown_links() {
1367 let rule = MD057ExistingRelativeLinks::new();
1368 let content = r#"
1369# Document
1370
1371[Link to docs](./docs/guide.md)
1372[Link with fragment](./other.md#section)
1373[External link](https://example.com)
1374[Image link](image.png)
1375[Media file](video.mp4)
1376"#;
1377
1378 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1379 let mut index = FileIndex::new();
1380 rule.contribute_to_index(&ctx, &mut index);
1381
1382 assert_eq!(index.cross_file_links.len(), 2);
1384
1385 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1387 assert_eq!(index.cross_file_links[0].fragment, "");
1388
1389 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1391 assert_eq!(index.cross_file_links[1].fragment, "section");
1392 }
1393
1394 #[test]
1395 fn test_contribute_to_index_skips_external_and_anchors() {
1396 let rule = MD057ExistingRelativeLinks::new();
1397 let content = r#"
1398# Document
1399
1400[External](https://example.com)
1401[Another external](http://example.org)
1402[Fragment only](#section)
1403[FTP link](ftp://files.example.com)
1404[Mail link](mailto:test@example.com)
1405[WWW link](www.example.com)
1406"#;
1407
1408 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1409 let mut index = FileIndex::new();
1410 rule.contribute_to_index(&ctx, &mut index);
1411
1412 assert_eq!(index.cross_file_links.len(), 0);
1414 }
1415
1416 #[test]
1417 fn test_cross_file_check_valid_link() {
1418 use crate::workspace_index::WorkspaceIndex;
1419
1420 let rule = MD057ExistingRelativeLinks::new();
1421
1422 let mut workspace_index = WorkspaceIndex::new();
1424 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1425
1426 let mut file_index = FileIndex::new();
1428 file_index.add_cross_file_link(CrossFileLinkIndex {
1429 target_path: "guide.md".to_string(),
1430 fragment: "".to_string(),
1431 line: 5,
1432 column: 1,
1433 });
1434
1435 let warnings = rule
1437 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1438 .unwrap();
1439
1440 assert!(warnings.is_empty());
1442 }
1443
1444 #[test]
1445 fn test_cross_file_check_missing_link() {
1446 use crate::workspace_index::WorkspaceIndex;
1447
1448 let rule = MD057ExistingRelativeLinks::new();
1449
1450 let workspace_index = WorkspaceIndex::new();
1452
1453 let mut file_index = FileIndex::new();
1455 file_index.add_cross_file_link(CrossFileLinkIndex {
1456 target_path: "missing.md".to_string(),
1457 fragment: "".to_string(),
1458 line: 5,
1459 column: 1,
1460 });
1461
1462 let warnings = rule
1464 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1465 .unwrap();
1466
1467 assert_eq!(warnings.len(), 1);
1469 assert!(warnings[0].message.contains("missing.md"));
1470 assert!(warnings[0].message.contains("does not exist"));
1471 }
1472
1473 #[test]
1474 fn test_cross_file_check_parent_path() {
1475 use crate::workspace_index::WorkspaceIndex;
1476
1477 let rule = MD057ExistingRelativeLinks::new();
1478
1479 let mut workspace_index = WorkspaceIndex::new();
1481 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1482
1483 let mut file_index = FileIndex::new();
1485 file_index.add_cross_file_link(CrossFileLinkIndex {
1486 target_path: "../readme.md".to_string(),
1487 fragment: "".to_string(),
1488 line: 5,
1489 column: 1,
1490 });
1491
1492 let warnings = rule
1494 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1495 .unwrap();
1496
1497 assert!(warnings.is_empty());
1499 }
1500
1501 #[test]
1502 fn test_cross_file_check_html_link_with_md_source() {
1503 use crate::workspace_index::WorkspaceIndex;
1506
1507 let rule = MD057ExistingRelativeLinks::new();
1508
1509 let mut workspace_index = WorkspaceIndex::new();
1511 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1512
1513 let mut file_index = FileIndex::new();
1515 file_index.add_cross_file_link(CrossFileLinkIndex {
1516 target_path: "guide.html".to_string(),
1517 fragment: "section".to_string(),
1518 line: 10,
1519 column: 5,
1520 });
1521
1522 let warnings = rule
1524 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1525 .unwrap();
1526
1527 assert!(
1529 warnings.is_empty(),
1530 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1531 );
1532 }
1533
1534 #[test]
1535 fn test_cross_file_check_html_link_without_source() {
1536 use crate::workspace_index::WorkspaceIndex;
1538
1539 let rule = MD057ExistingRelativeLinks::new();
1540
1541 let workspace_index = WorkspaceIndex::new();
1543
1544 let mut file_index = FileIndex::new();
1546 file_index.add_cross_file_link(CrossFileLinkIndex {
1547 target_path: "missing.html".to_string(),
1548 fragment: "".to_string(),
1549 line: 10,
1550 column: 5,
1551 });
1552
1553 let warnings = rule
1555 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1556 .unwrap();
1557
1558 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1560 assert!(warnings[0].message.contains("missing.html"));
1561 }
1562
1563 #[test]
1564 fn test_normalize_path_function() {
1565 assert_eq!(
1567 normalize_path(Path::new("docs/guide.md")),
1568 PathBuf::from("docs/guide.md")
1569 );
1570
1571 assert_eq!(
1573 normalize_path(Path::new("./docs/guide.md")),
1574 PathBuf::from("docs/guide.md")
1575 );
1576
1577 assert_eq!(
1579 normalize_path(Path::new("docs/sub/../guide.md")),
1580 PathBuf::from("docs/guide.md")
1581 );
1582
1583 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1585 }
1586
1587 #[test]
1588 fn test_html_link_with_md_source() {
1589 let temp_dir = tempdir().unwrap();
1591 let base_path = temp_dir.path();
1592
1593 let md_file = base_path.join("guide.md");
1595 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1596
1597 let content = r#"
1598[Read the guide](guide.html)
1599[Also here](getting-started.html)
1600"#;
1601
1602 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1603 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1604 let result = rule.check(&ctx).unwrap();
1605
1606 assert_eq!(
1608 result.len(),
1609 1,
1610 "Should only warn about missing source. Got: {result:?}"
1611 );
1612 assert!(result[0].message.contains("getting-started.html"));
1613 }
1614
1615 #[test]
1616 fn test_htm_link_with_md_source() {
1617 let temp_dir = tempdir().unwrap();
1619 let base_path = temp_dir.path();
1620
1621 let md_file = base_path.join("page.md");
1622 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1623
1624 let content = "[Page](page.htm)";
1625
1626 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1628 let result = rule.check(&ctx).unwrap();
1629
1630 assert!(
1631 result.is_empty(),
1632 "Should not warn when .md source exists for .htm link"
1633 );
1634 }
1635
1636 #[test]
1637 fn test_html_link_finds_various_markdown_extensions() {
1638 let temp_dir = tempdir().unwrap();
1640 let base_path = temp_dir.path();
1641
1642 File::create(base_path.join("doc.md")).unwrap();
1643 File::create(base_path.join("tutorial.mdx")).unwrap();
1644 File::create(base_path.join("guide.markdown")).unwrap();
1645
1646 let content = r#"
1647[Doc](doc.html)
1648[Tutorial](tutorial.html)
1649[Guide](guide.html)
1650"#;
1651
1652 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1653 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654 let result = rule.check(&ctx).unwrap();
1655
1656 assert!(
1657 result.is_empty(),
1658 "Should find all markdown variants as source files. Got: {result:?}"
1659 );
1660 }
1661
1662 #[test]
1663 fn test_html_link_in_subdirectory() {
1664 let temp_dir = tempdir().unwrap();
1666 let base_path = temp_dir.path();
1667
1668 let docs_dir = base_path.join("docs");
1669 std::fs::create_dir(&docs_dir).unwrap();
1670 File::create(docs_dir.join("guide.md"))
1671 .unwrap()
1672 .write_all(b"# Guide")
1673 .unwrap();
1674
1675 let content = "[Guide](docs/guide.html)";
1676
1677 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1678 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1679 let result = rule.check(&ctx).unwrap();
1680
1681 assert!(result.is_empty(), "Should find markdown source in subdirectory");
1682 }
1683
1684 #[test]
1685 fn test_absolute_path_skipped_in_check() {
1686 let temp_dir = tempdir().unwrap();
1689 let base_path = temp_dir.path();
1690
1691 let content = r#"
1692# Test Document
1693
1694[Go Runtime](/pkg/runtime)
1695[Go Runtime with Fragment](/pkg/runtime#section)
1696[API Docs](/api/v1/users)
1697[Blog Post](/blog/2024/release.html)
1698[React Hook](/react/hooks/use-state.html)
1699"#;
1700
1701 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704
1705 assert!(
1707 result.is_empty(),
1708 "Absolute paths should be skipped. Got warnings: {result:?}"
1709 );
1710 }
1711
1712 #[test]
1713 fn test_absolute_path_skipped_in_cross_file_check() {
1714 use crate::workspace_index::WorkspaceIndex;
1716
1717 let rule = MD057ExistingRelativeLinks::new();
1718
1719 let workspace_index = WorkspaceIndex::new();
1721
1722 let mut file_index = FileIndex::new();
1724 file_index.add_cross_file_link(CrossFileLinkIndex {
1725 target_path: "/pkg/runtime.md".to_string(),
1726 fragment: "".to_string(),
1727 line: 5,
1728 column: 1,
1729 });
1730 file_index.add_cross_file_link(CrossFileLinkIndex {
1731 target_path: "/api/v1/users.md".to_string(),
1732 fragment: "section".to_string(),
1733 line: 10,
1734 column: 1,
1735 });
1736
1737 let warnings = rule
1739 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1740 .unwrap();
1741
1742 assert!(
1744 warnings.is_empty(),
1745 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1746 );
1747 }
1748
1749 #[test]
1750 fn test_protocol_relative_url_not_skipped() {
1751 let temp_dir = tempdir().unwrap();
1754 let base_path = temp_dir.path();
1755
1756 let content = r#"
1757# Test Document
1758
1759[External](//example.com/page)
1760[Another](//cdn.example.com/asset.js)
1761"#;
1762
1763 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1764 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1765 let result = rule.check(&ctx).unwrap();
1766
1767 assert!(
1769 result.is_empty(),
1770 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
1771 );
1772 }
1773
1774 #[test]
1775 fn test_email_addresses_skipped() {
1776 let temp_dir = tempdir().unwrap();
1779 let base_path = temp_dir.path();
1780
1781 let content = r#"
1782# Test Document
1783
1784[Contact](user@example.com)
1785[Steering](steering@kubernetes.io)
1786[Support](john.doe+filter@company.co.uk)
1787[User](user_name@sub.domain.com)
1788"#;
1789
1790 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1791 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1792 let result = rule.check(&ctx).unwrap();
1793
1794 assert!(
1796 result.is_empty(),
1797 "Email addresses should be skipped. Got warnings: {result:?}"
1798 );
1799 }
1800
1801 #[test]
1802 fn test_email_addresses_vs_file_paths() {
1803 let temp_dir = tempdir().unwrap();
1806 let base_path = temp_dir.path();
1807
1808 let content = r#"
1809# Test Document
1810
1811[Email](user@example.com) <!-- Should be skipped (email) -->
1812[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
1813[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
1814"#;
1815
1816 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let result = rule.check(&ctx).unwrap();
1819
1820 assert!(
1822 result.is_empty(),
1823 "All email addresses should be skipped. Got: {result:?}"
1824 );
1825 }
1826
1827 #[test]
1828 fn test_diagnostic_position_accuracy() {
1829 let temp_dir = tempdir().unwrap();
1831 let base_path = temp_dir.path();
1832
1833 let content = "prefix [text](missing.md) suffix";
1836 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1840 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1841 let result = rule.check(&ctx).unwrap();
1842
1843 assert_eq!(result.len(), 1, "Should have exactly one warning");
1844 assert_eq!(result[0].line, 1, "Should be on line 1");
1845 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
1846 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
1847 }
1848
1849 #[test]
1850 fn test_diagnostic_position_angle_brackets() {
1851 let temp_dir = tempdir().unwrap();
1853 let base_path = temp_dir.path();
1854
1855 let content = "[link](<missing.md>)";
1858 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1861 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1862 let result = rule.check(&ctx).unwrap();
1863
1864 assert_eq!(result.len(), 1, "Should have exactly one warning");
1865 assert_eq!(result[0].line, 1, "Should be on line 1");
1866 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
1867 }
1868
1869 #[test]
1870 fn test_diagnostic_position_multiline() {
1871 let temp_dir = tempdir().unwrap();
1873 let base_path = temp_dir.path();
1874
1875 let content = r#"# Title
1876Some text on line 2
1877[link on line 3](missing1.md)
1878More text
1879[link on line 5](missing2.md)"#;
1880
1881 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1882 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1883 let result = rule.check(&ctx).unwrap();
1884
1885 assert_eq!(result.len(), 2, "Should have two warnings");
1886
1887 assert_eq!(result[0].line, 3, "First warning should be on line 3");
1889 assert!(result[0].message.contains("missing1.md"));
1890
1891 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
1893 assert!(result[1].message.contains("missing2.md"));
1894 }
1895
1896 #[test]
1897 fn test_diagnostic_position_with_spaces() {
1898 let temp_dir = tempdir().unwrap();
1900 let base_path = temp_dir.path();
1901
1902 let content = "[link]( missing.md )";
1903 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1908 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1909 let result = rule.check(&ctx).unwrap();
1910
1911 assert_eq!(result.len(), 1, "Should have exactly one warning");
1912 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
1914 }
1915
1916 #[test]
1917 fn test_diagnostic_position_image() {
1918 let temp_dir = tempdir().unwrap();
1920 let base_path = temp_dir.path();
1921
1922 let content = "";
1923
1924 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1925 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1926 let result = rule.check(&ctx).unwrap();
1927
1928 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
1929 assert_eq!(result[0].line, 1);
1930 assert!(result[0].column > 0, "Should have valid column position");
1932 assert!(result[0].message.contains("missing.jpg"));
1933 }
1934}