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 let mut processed_lines = std::collections::HashSet::new();
348
349 for link in &ctx.links {
350 let line_idx = link.line - 1;
351 if line_idx >= lines.len() {
352 continue;
353 }
354
355 if !processed_lines.insert(line_idx) {
357 continue;
358 }
359
360 let line = lines[line_idx];
361
362 if !line.contains("](") {
364 continue;
365 }
366
367 for link_match in LINK_START_REGEX.find_iter(line) {
369 let start_pos = link_match.start();
370 let end_pos = link_match.end();
371
372 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
374 let absolute_start_pos = line_start_byte + start_pos;
375
376 if element_cache.is_in_code_span(absolute_start_pos) {
378 continue;
379 }
380
381 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
385 .captures_at(line, end_pos - 1)
386 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
387 .or_else(|| {
388 URL_EXTRACT_REGEX
389 .captures_at(line, end_pos - 1)
390 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
391 });
392
393 if let Some((_caps, url_group)) = caps_and_url {
394 let url = url_group.as_str().trim();
395
396 if url.is_empty() {
398 continue;
399 }
400
401 if url.starts_with('`') && url.ends_with('`') {
405 continue;
406 }
407
408 if self.is_external_url(url) || self.is_fragment_only_link(url) {
410 continue;
411 }
412
413 let file_path = Self::strip_query_and_fragment(url);
415
416 let decoded_path = Self::url_decode(file_path);
418
419 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
421
422 if file_exists_or_markdown_extension(&resolved_path) {
424 continue; }
426
427 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
429 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
430 && let (Some(stem), Some(parent)) = (
431 resolved_path.file_stem().and_then(|s| s.to_str()),
432 resolved_path.parent(),
433 ) {
434 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
435 let source_path = parent.join(format!("{stem}{md_ext}"));
436 file_exists_with_cache(&source_path)
437 })
438 } else {
439 false
440 };
441
442 if has_md_source {
443 continue; }
445
446 let url_start = url_group.start();
450 let url_end = url_group.end();
451
452 warnings.push(LintWarning {
453 rule_name: Some(self.name().to_string()),
454 line: link.line,
455 column: url_start + 1, end_line: link.line,
457 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
459 severity: Severity::Error,
460 fix: None,
461 });
462 }
463 }
464 }
465 }
466
467 for image in &ctx.images {
469 let url = image.url.as_ref();
470
471 if url.is_empty() {
473 continue;
474 }
475
476 if self.is_external_url(url) || self.is_fragment_only_link(url) {
478 continue;
479 }
480
481 let file_path = Self::strip_query_and_fragment(url);
483
484 let decoded_path = Self::url_decode(file_path);
486
487 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
489
490 if file_exists_or_markdown_extension(&resolved_path) {
492 continue; }
494
495 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
497 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
498 && let (Some(stem), Some(parent)) = (
499 resolved_path.file_stem().and_then(|s| s.to_str()),
500 resolved_path.parent(),
501 ) {
502 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
503 let source_path = parent.join(format!("{stem}{md_ext}"));
504 file_exists_with_cache(&source_path)
505 })
506 } else {
507 false
508 };
509
510 if has_md_source {
511 continue; }
513
514 warnings.push(LintWarning {
517 rule_name: Some(self.name().to_string()),
518 line: image.line,
519 column: image.start_col + 1,
520 end_line: image.line,
521 end_column: image.start_col + 1 + url.len(),
522 message: format!("Relative link '{url}' does not exist"),
523 severity: Severity::Error,
524 fix: None,
525 });
526 }
527
528 Ok(warnings)
529 }
530
531 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
532 Ok(ctx.content.to_string())
533 }
534
535 fn as_any(&self) -> &dyn std::any::Any {
536 self
537 }
538
539 fn default_config_section(&self) -> Option<(String, toml::Value)> {
540 None
542 }
543
544 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
545 where
546 Self: Sized,
547 {
548 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
549 Box::new(Self::from_config_struct(rule_config))
550 }
551
552 fn cross_file_scope(&self) -> CrossFileScope {
553 CrossFileScope::Workspace
554 }
555
556 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
557 for link in extract_cross_file_links(ctx) {
560 index.add_cross_file_link(link);
561 }
562 }
563
564 fn cross_file_check(
565 &self,
566 file_path: &Path,
567 file_index: &FileIndex,
568 workspace_index: &crate::workspace_index::WorkspaceIndex,
569 ) -> LintResult {
570 let mut warnings = Vec::new();
571
572 let file_dir = file_path.parent();
574
575 for cross_link in &file_index.cross_file_links {
576 let decoded_target = Self::url_decode(&cross_link.target_path);
579
580 if decoded_target.starts_with('/') {
582 continue;
583 }
584
585 let target_path = if let Some(dir) = file_dir {
587 dir.join(&decoded_target)
588 } else {
589 Path::new(&decoded_target).to_path_buf()
590 };
591
592 let target_path = normalize_path(&target_path);
594
595 let file_exists =
597 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
598
599 if !file_exists {
600 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
603 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
604 && let (Some(stem), Some(parent)) =
605 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
606 {
607 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
608 let source_path = parent.join(format!("{stem}{md_ext}"));
609 workspace_index.contains_file(&source_path) || source_path.exists()
610 })
611 } else {
612 false
613 };
614
615 if !has_md_source {
616 warnings.push(LintWarning {
617 rule_name: Some(self.name().to_string()),
618 line: cross_link.line,
619 column: cross_link.column,
620 end_line: cross_link.line,
621 end_column: cross_link.column + cross_link.target_path.len(),
622 message: format!("Relative link '{}' does not exist", cross_link.target_path),
623 severity: Severity::Error,
624 fix: None,
625 });
626 }
627 }
628 }
629
630 Ok(warnings)
631 }
632}
633
634fn normalize_path(path: &Path) -> PathBuf {
636 let mut components = Vec::new();
637
638 for component in path.components() {
639 match component {
640 std::path::Component::ParentDir => {
641 if !components.is_empty() {
643 components.pop();
644 }
645 }
646 std::path::Component::CurDir => {
647 }
649 _ => {
650 components.push(component);
651 }
652 }
653 }
654
655 components.iter().collect()
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use crate::workspace_index::CrossFileLinkIndex;
662 use std::fs::File;
663 use std::io::Write;
664 use tempfile::tempdir;
665
666 #[test]
667 fn test_strip_query_and_fragment() {
668 assert_eq!(
670 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
671 "file.png"
672 );
673 assert_eq!(
674 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
675 "file.png"
676 );
677 assert_eq!(
678 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
679 "file.png"
680 );
681
682 assert_eq!(
684 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
685 "file.md"
686 );
687 assert_eq!(
688 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
689 "file.md"
690 );
691
692 assert_eq!(
694 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
695 "file.md"
696 );
697
698 assert_eq!(
700 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
701 "file.png"
702 );
703
704 assert_eq!(
706 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
707 "path/to/image.png"
708 );
709 assert_eq!(
710 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
711 "path/to/image.png"
712 );
713
714 assert_eq!(
716 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
717 "file.md"
718 );
719 }
720
721 #[test]
722 fn test_url_decode() {
723 assert_eq!(
725 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
726 "penguin with space.jpg"
727 );
728
729 assert_eq!(
731 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
732 "assets/my file name.png"
733 );
734
735 assert_eq!(
737 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
738 "hello world!.md"
739 );
740
741 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
743
744 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
746
747 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
749
750 assert_eq!(
752 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
753 "normal-file.md"
754 );
755
756 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
758
759 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
761
762 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
764
765 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
767
768 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
770
771 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
773
774 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
776
777 assert_eq!(
779 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
780 "path/to/file.md"
781 );
782
783 assert_eq!(
785 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
786 "hello world/foo bar.md"
787 );
788
789 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
791
792 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
794 }
795
796 #[test]
797 fn test_url_encoded_filenames() {
798 let temp_dir = tempdir().unwrap();
800 let base_path = temp_dir.path();
801
802 let file_with_spaces = base_path.join("penguin with space.jpg");
804 File::create(&file_with_spaces)
805 .unwrap()
806 .write_all(b"image data")
807 .unwrap();
808
809 let subdir = base_path.join("my images");
811 std::fs::create_dir(&subdir).unwrap();
812 let nested_file = subdir.join("photo 1.png");
813 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
814
815 let content = r#"
817# Test Document with URL-Encoded Links
818
819
820
821
822"#;
823
824 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
825
826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
827 let result = rule.check(&ctx).unwrap();
828
829 assert_eq!(
831 result.len(),
832 1,
833 "Should only warn about missing%20file.jpg. Got: {result:?}"
834 );
835 assert!(
836 result[0].message.contains("missing%20file.jpg"),
837 "Warning should mention the URL-encoded filename"
838 );
839 }
840
841 #[test]
842 fn test_external_urls() {
843 let rule = MD057ExistingRelativeLinks::new();
844
845 assert!(rule.is_external_url("https://example.com"));
847 assert!(rule.is_external_url("http://example.com"));
848 assert!(rule.is_external_url("ftp://example.com"));
849 assert!(rule.is_external_url("www.example.com"));
850 assert!(rule.is_external_url("example.com"));
851
852 assert!(rule.is_external_url("file:///path/to/file"));
854 assert!(rule.is_external_url("smb://server/share"));
855 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
856 assert!(rule.is_external_url("mailto:user@example.com"));
857 assert!(rule.is_external_url("tel:+1234567890"));
858 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
859 assert!(rule.is_external_url("javascript:void(0)"));
860 assert!(rule.is_external_url("ssh://git@github.com/repo"));
861 assert!(rule.is_external_url("git://github.com/repo.git"));
862
863 assert!(rule.is_external_url("user@example.com"));
866 assert!(rule.is_external_url("steering@kubernetes.io"));
867 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
868 assert!(rule.is_external_url("user_name@sub.domain.com"));
869 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
870
871 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"));
882 assert!(rule.is_external_url("/blog/2024/release.html"));
883 assert!(rule.is_external_url("/react/hooks/use-state.html"));
884 assert!(rule.is_external_url("/pkg/runtime"));
885 assert!(rule.is_external_url("/doc/go1compat"));
886 assert!(rule.is_external_url("/index.html"));
887 assert!(rule.is_external_url("/assets/logo.png"));
888
889 assert!(rule.is_external_url("~/assets/image.png"));
892 assert!(rule.is_external_url("~/components/Button.vue"));
893 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
897 assert!(rule.is_external_url("@images/photo.jpg"));
898 assert!(rule.is_external_url("@assets/styles.css"));
899
900 assert!(!rule.is_external_url("./relative/path.md"));
902 assert!(!rule.is_external_url("relative/path.md"));
903 assert!(!rule.is_external_url("../parent/path.md"));
904 }
905
906 #[test]
907 fn test_framework_path_aliases() {
908 let temp_dir = tempdir().unwrap();
910 let base_path = temp_dir.path();
911
912 let content = r#"
914# Framework Path Aliases
915
916
917
918
919
920[Link](@/pages/about.md)
921
922This is a [real missing link](missing.md) that should be flagged.
923"#;
924
925 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
926
927 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
928 let result = rule.check(&ctx).unwrap();
929
930 assert_eq!(
932 result.len(),
933 1,
934 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
935 );
936 assert!(
937 result[0].message.contains("missing.md"),
938 "Warning should be for missing.md"
939 );
940 }
941
942 #[test]
943 fn test_url_decode_security_path_traversal() {
944 let temp_dir = tempdir().unwrap();
947 let base_path = temp_dir.path();
948
949 let file_in_base = base_path.join("safe.md");
951 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
952
953 let content = r#"
958[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
959[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
960[Safe link](safe.md)
961"#;
962
963 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
964
965 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
966 let result = rule.check(&ctx).unwrap();
967
968 assert_eq!(
971 result.len(),
972 2,
973 "Should have warnings for traversal attempts. Got: {result:?}"
974 );
975 }
976
977 #[test]
978 fn test_url_encoded_utf8_filenames() {
979 let temp_dir = tempdir().unwrap();
981 let base_path = temp_dir.path();
982
983 let cafe_file = base_path.join("café.md");
985 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
986
987 let content = r#"
988[Café link](caf%C3%A9.md)
989[Missing unicode](r%C3%A9sum%C3%A9.md)
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 résumé.md. Got: {result:?}"
1002 );
1003 assert!(
1004 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1005 "Warning should mention the URL-encoded filename"
1006 );
1007 }
1008
1009 #[test]
1010 fn test_url_encoded_emoji_filenames() {
1011 let temp_dir = tempdir().unwrap();
1014 let base_path = temp_dir.path();
1015
1016 let emoji_dir = base_path.join("👤 Personal");
1018 std::fs::create_dir(&emoji_dir).unwrap();
1019
1020 let file_path = emoji_dir.join("TV Shows.md");
1022 File::create(&file_path)
1023 .unwrap()
1024 .write_all(b"# TV Shows\n\nContent here.")
1025 .unwrap();
1026
1027 let content = r#"
1030# Test Document
1031
1032[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1033[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1034"#;
1035
1036 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1037
1038 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039 let result = rule.check(&ctx).unwrap();
1040
1041 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1043 assert!(
1044 result[0].message.contains("Missing.md"),
1045 "Warning should be for Missing.md, got: {}",
1046 result[0].message
1047 );
1048 }
1049
1050 #[test]
1051 fn test_no_warnings_without_base_path() {
1052 let rule = MD057ExistingRelativeLinks::new();
1053 let content = "[Link](missing.md)";
1054
1055 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056 let result = rule.check(&ctx).unwrap();
1057 assert!(result.is_empty(), "Should have no warnings without base path");
1058 }
1059
1060 #[test]
1061 fn test_existing_and_missing_links() {
1062 let temp_dir = tempdir().unwrap();
1064 let base_path = temp_dir.path();
1065
1066 let exists_path = base_path.join("exists.md");
1068 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1069
1070 assert!(exists_path.exists(), "exists.md should exist for this test");
1072
1073 let content = r#"
1075# Test Document
1076
1077[Valid Link](exists.md)
1078[Invalid Link](missing.md)
1079[External Link](https://example.com)
1080[Media Link](image.jpg)
1081 "#;
1082
1083 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1085
1086 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1088 let result = rule.check(&ctx).unwrap();
1089
1090 assert_eq!(result.len(), 2);
1092 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1093 assert!(messages.iter().any(|m| m.contains("missing.md")));
1094 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1095 }
1096
1097 #[test]
1098 fn test_angle_bracket_links() {
1099 let temp_dir = tempdir().unwrap();
1101 let base_path = temp_dir.path();
1102
1103 let exists_path = base_path.join("exists.md");
1105 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1106
1107 let content = r#"
1109# Test Document
1110
1111[Valid Link](<exists.md>)
1112[Invalid Link](<missing.md>)
1113[External Link](<https://example.com>)
1114 "#;
1115
1116 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1118
1119 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1120 let result = rule.check(&ctx).unwrap();
1121
1122 assert_eq!(result.len(), 1, "Should have exactly one warning");
1124 assert!(
1125 result[0].message.contains("missing.md"),
1126 "Warning should mention missing.md"
1127 );
1128 }
1129
1130 #[test]
1131 fn test_angle_bracket_links_with_parens() {
1132 let temp_dir = tempdir().unwrap();
1134 let base_path = temp_dir.path();
1135
1136 let app_dir = base_path.join("app");
1138 std::fs::create_dir(&app_dir).unwrap();
1139 let upload_dir = app_dir.join("(upload)");
1140 std::fs::create_dir(&upload_dir).unwrap();
1141 let page_file = upload_dir.join("page.tsx");
1142 File::create(&page_file)
1143 .unwrap()
1144 .write_all(b"export default function Page() {}")
1145 .unwrap();
1146
1147 let content = r#"
1149# Test Document with Paths Containing Parens
1150
1151[Upload Page](<app/(upload)/page.tsx>)
1152[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1153[Missing](<app/(missing)/file.md>)
1154"#;
1155
1156 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1157
1158 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1159 let result = rule.check(&ctx).unwrap();
1160
1161 assert_eq!(
1163 result.len(),
1164 1,
1165 "Should have exactly one warning for missing file. Got: {result:?}"
1166 );
1167 assert!(
1168 result[0].message.contains("app/(missing)/file.md"),
1169 "Warning should mention app/(missing)/file.md"
1170 );
1171 }
1172
1173 #[test]
1174 fn test_all_file_types_checked() {
1175 let temp_dir = tempdir().unwrap();
1177 let base_path = temp_dir.path();
1178
1179 let content = r#"
1181[Image Link](image.jpg)
1182[Video Link](video.mp4)
1183[Markdown Link](document.md)
1184[PDF Link](file.pdf)
1185"#;
1186
1187 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1188
1189 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190 let result = rule.check(&ctx).unwrap();
1191
1192 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1194 }
1195
1196 #[test]
1197 fn test_code_span_detection() {
1198 let rule = MD057ExistingRelativeLinks::new();
1199
1200 let temp_dir = tempdir().unwrap();
1202 let base_path = temp_dir.path();
1203
1204 let rule = rule.with_path(base_path);
1205
1206 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1208
1209 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1210 let result = rule.check(&ctx).unwrap();
1211
1212 assert_eq!(result.len(), 1, "Should only flag the real link");
1214 assert!(result[0].message.contains("nonexistent.md"));
1215 }
1216
1217 #[test]
1218 fn test_inline_code_spans() {
1219 let temp_dir = tempdir().unwrap();
1221 let base_path = temp_dir.path();
1222
1223 let content = r#"
1225# Test Document
1226
1227This is a normal link: [Link](missing.md)
1228
1229This is a code span with a link: `[Link](another-missing.md)`
1230
1231Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1232
1233 "#;
1234
1235 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1237
1238 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1240 let result = rule.check(&ctx).unwrap();
1241
1242 assert_eq!(result.len(), 1, "Should have exactly one warning");
1244 assert!(
1245 result[0].message.contains("missing.md"),
1246 "Warning should be for missing.md"
1247 );
1248 assert!(
1249 !result.iter().any(|w| w.message.contains("another-missing.md")),
1250 "Should not warn about link in code span"
1251 );
1252 assert!(
1253 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1254 "Should not warn about link in inline code"
1255 );
1256 }
1257
1258 #[test]
1259 fn test_extensionless_link_resolution() {
1260 let temp_dir = tempdir().unwrap();
1262 let base_path = temp_dir.path();
1263
1264 let page_path = base_path.join("page.md");
1266 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1267
1268 let content = r#"
1270# Test Document
1271
1272[Link without extension](page)
1273[Link with extension](page.md)
1274[Missing link](nonexistent)
1275"#;
1276
1277 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1278
1279 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1280 let result = rule.check(&ctx).unwrap();
1281
1282 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1285 assert!(
1286 result[0].message.contains("nonexistent"),
1287 "Warning should be for 'nonexistent' not 'page'"
1288 );
1289 }
1290
1291 #[test]
1293 fn test_cross_file_scope() {
1294 let rule = MD057ExistingRelativeLinks::new();
1295 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1296 }
1297
1298 #[test]
1299 fn test_contribute_to_index_extracts_markdown_links() {
1300 let rule = MD057ExistingRelativeLinks::new();
1301 let content = r#"
1302# Document
1303
1304[Link to docs](./docs/guide.md)
1305[Link with fragment](./other.md#section)
1306[External link](https://example.com)
1307[Image link](image.png)
1308[Media file](video.mp4)
1309"#;
1310
1311 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1312 let mut index = FileIndex::new();
1313 rule.contribute_to_index(&ctx, &mut index);
1314
1315 assert_eq!(index.cross_file_links.len(), 2);
1317
1318 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1320 assert_eq!(index.cross_file_links[0].fragment, "");
1321
1322 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1324 assert_eq!(index.cross_file_links[1].fragment, "section");
1325 }
1326
1327 #[test]
1328 fn test_contribute_to_index_skips_external_and_anchors() {
1329 let rule = MD057ExistingRelativeLinks::new();
1330 let content = r#"
1331# Document
1332
1333[External](https://example.com)
1334[Another external](http://example.org)
1335[Fragment only](#section)
1336[FTP link](ftp://files.example.com)
1337[Mail link](mailto:test@example.com)
1338[WWW link](www.example.com)
1339"#;
1340
1341 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1342 let mut index = FileIndex::new();
1343 rule.contribute_to_index(&ctx, &mut index);
1344
1345 assert_eq!(index.cross_file_links.len(), 0);
1347 }
1348
1349 #[test]
1350 fn test_cross_file_check_valid_link() {
1351 use crate::workspace_index::WorkspaceIndex;
1352
1353 let rule = MD057ExistingRelativeLinks::new();
1354
1355 let mut workspace_index = WorkspaceIndex::new();
1357 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1358
1359 let mut file_index = FileIndex::new();
1361 file_index.add_cross_file_link(CrossFileLinkIndex {
1362 target_path: "guide.md".to_string(),
1363 fragment: "".to_string(),
1364 line: 5,
1365 column: 1,
1366 });
1367
1368 let warnings = rule
1370 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1371 .unwrap();
1372
1373 assert!(warnings.is_empty());
1375 }
1376
1377 #[test]
1378 fn test_cross_file_check_missing_link() {
1379 use crate::workspace_index::WorkspaceIndex;
1380
1381 let rule = MD057ExistingRelativeLinks::new();
1382
1383 let workspace_index = WorkspaceIndex::new();
1385
1386 let mut file_index = FileIndex::new();
1388 file_index.add_cross_file_link(CrossFileLinkIndex {
1389 target_path: "missing.md".to_string(),
1390 fragment: "".to_string(),
1391 line: 5,
1392 column: 1,
1393 });
1394
1395 let warnings = rule
1397 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1398 .unwrap();
1399
1400 assert_eq!(warnings.len(), 1);
1402 assert!(warnings[0].message.contains("missing.md"));
1403 assert!(warnings[0].message.contains("does not exist"));
1404 }
1405
1406 #[test]
1407 fn test_cross_file_check_parent_path() {
1408 use crate::workspace_index::WorkspaceIndex;
1409
1410 let rule = MD057ExistingRelativeLinks::new();
1411
1412 let mut workspace_index = WorkspaceIndex::new();
1414 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1415
1416 let mut file_index = FileIndex::new();
1418 file_index.add_cross_file_link(CrossFileLinkIndex {
1419 target_path: "../readme.md".to_string(),
1420 fragment: "".to_string(),
1421 line: 5,
1422 column: 1,
1423 });
1424
1425 let warnings = rule
1427 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
1428 .unwrap();
1429
1430 assert!(warnings.is_empty());
1432 }
1433
1434 #[test]
1435 fn test_cross_file_check_html_link_with_md_source() {
1436 use crate::workspace_index::WorkspaceIndex;
1439
1440 let rule = MD057ExistingRelativeLinks::new();
1441
1442 let mut workspace_index = WorkspaceIndex::new();
1444 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1445
1446 let mut file_index = FileIndex::new();
1448 file_index.add_cross_file_link(CrossFileLinkIndex {
1449 target_path: "guide.html".to_string(),
1450 fragment: "section".to_string(),
1451 line: 10,
1452 column: 5,
1453 });
1454
1455 let warnings = rule
1457 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1458 .unwrap();
1459
1460 assert!(
1462 warnings.is_empty(),
1463 "Expected no warnings for .html link with .md source, got: {warnings:?}"
1464 );
1465 }
1466
1467 #[test]
1468 fn test_cross_file_check_html_link_without_source() {
1469 use crate::workspace_index::WorkspaceIndex;
1471
1472 let rule = MD057ExistingRelativeLinks::new();
1473
1474 let workspace_index = WorkspaceIndex::new();
1476
1477 let mut file_index = FileIndex::new();
1479 file_index.add_cross_file_link(CrossFileLinkIndex {
1480 target_path: "missing.html".to_string(),
1481 fragment: "".to_string(),
1482 line: 10,
1483 column: 5,
1484 });
1485
1486 let warnings = rule
1488 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1489 .unwrap();
1490
1491 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
1493 assert!(warnings[0].message.contains("missing.html"));
1494 }
1495
1496 #[test]
1497 fn test_normalize_path_function() {
1498 assert_eq!(
1500 normalize_path(Path::new("docs/guide.md")),
1501 PathBuf::from("docs/guide.md")
1502 );
1503
1504 assert_eq!(
1506 normalize_path(Path::new("./docs/guide.md")),
1507 PathBuf::from("docs/guide.md")
1508 );
1509
1510 assert_eq!(
1512 normalize_path(Path::new("docs/sub/../guide.md")),
1513 PathBuf::from("docs/guide.md")
1514 );
1515
1516 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
1518 }
1519
1520 #[test]
1521 fn test_html_link_with_md_source() {
1522 let temp_dir = tempdir().unwrap();
1524 let base_path = temp_dir.path();
1525
1526 let md_file = base_path.join("guide.md");
1528 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
1529
1530 let content = r#"
1531[Read the guide](guide.html)
1532[Also here](getting-started.html)
1533"#;
1534
1535 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1536 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1537 let result = rule.check(&ctx).unwrap();
1538
1539 assert_eq!(
1541 result.len(),
1542 1,
1543 "Should only warn about missing source. Got: {result:?}"
1544 );
1545 assert!(result[0].message.contains("getting-started.html"));
1546 }
1547
1548 #[test]
1549 fn test_htm_link_with_md_source() {
1550 let temp_dir = tempdir().unwrap();
1552 let base_path = temp_dir.path();
1553
1554 let md_file = base_path.join("page.md");
1555 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
1556
1557 let content = "[Page](page.htm)";
1558
1559 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1560 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1561 let result = rule.check(&ctx).unwrap();
1562
1563 assert!(
1564 result.is_empty(),
1565 "Should not warn when .md source exists for .htm link"
1566 );
1567 }
1568
1569 #[test]
1570 fn test_html_link_finds_various_markdown_extensions() {
1571 let temp_dir = tempdir().unwrap();
1573 let base_path = temp_dir.path();
1574
1575 File::create(base_path.join("doc.md")).unwrap();
1576 File::create(base_path.join("tutorial.mdx")).unwrap();
1577 File::create(base_path.join("guide.markdown")).unwrap();
1578
1579 let content = r#"
1580[Doc](doc.html)
1581[Tutorial](tutorial.html)
1582[Guide](guide.html)
1583"#;
1584
1585 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1587 let result = rule.check(&ctx).unwrap();
1588
1589 assert!(
1590 result.is_empty(),
1591 "Should find all markdown variants as source files. Got: {result:?}"
1592 );
1593 }
1594
1595 #[test]
1596 fn test_html_link_in_subdirectory() {
1597 let temp_dir = tempdir().unwrap();
1599 let base_path = temp_dir.path();
1600
1601 let docs_dir = base_path.join("docs");
1602 std::fs::create_dir(&docs_dir).unwrap();
1603 File::create(docs_dir.join("guide.md"))
1604 .unwrap()
1605 .write_all(b"# Guide")
1606 .unwrap();
1607
1608 let content = "[Guide](docs/guide.html)";
1609
1610 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1611 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1612 let result = rule.check(&ctx).unwrap();
1613
1614 assert!(result.is_empty(), "Should find markdown source in subdirectory");
1615 }
1616
1617 #[test]
1618 fn test_absolute_path_skipped_in_check() {
1619 let temp_dir = tempdir().unwrap();
1622 let base_path = temp_dir.path();
1623
1624 let content = r#"
1625# Test Document
1626
1627[Go Runtime](/pkg/runtime)
1628[Go Runtime with Fragment](/pkg/runtime#section)
1629[API Docs](/api/v1/users)
1630[Blog Post](/blog/2024/release.html)
1631[React Hook](/react/hooks/use-state.html)
1632"#;
1633
1634 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1635 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637
1638 assert!(
1640 result.is_empty(),
1641 "Absolute paths should be skipped. Got warnings: {result:?}"
1642 );
1643 }
1644
1645 #[test]
1646 fn test_absolute_path_skipped_in_cross_file_check() {
1647 use crate::workspace_index::WorkspaceIndex;
1649
1650 let rule = MD057ExistingRelativeLinks::new();
1651
1652 let workspace_index = WorkspaceIndex::new();
1654
1655 let mut file_index = FileIndex::new();
1657 file_index.add_cross_file_link(CrossFileLinkIndex {
1658 target_path: "/pkg/runtime.md".to_string(),
1659 fragment: "".to_string(),
1660 line: 5,
1661 column: 1,
1662 });
1663 file_index.add_cross_file_link(CrossFileLinkIndex {
1664 target_path: "/api/v1/users.md".to_string(),
1665 fragment: "section".to_string(),
1666 line: 10,
1667 column: 1,
1668 });
1669
1670 let warnings = rule
1672 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1673 .unwrap();
1674
1675 assert!(
1677 warnings.is_empty(),
1678 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
1679 );
1680 }
1681
1682 #[test]
1683 fn test_protocol_relative_url_not_skipped() {
1684 let temp_dir = tempdir().unwrap();
1687 let base_path = temp_dir.path();
1688
1689 let content = r#"
1690# Test Document
1691
1692[External](//example.com/page)
1693[Another](//cdn.example.com/asset.js)
1694"#;
1695
1696 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1697 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1698 let result = rule.check(&ctx).unwrap();
1699
1700 assert!(
1702 result.is_empty(),
1703 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_email_addresses_skipped() {
1709 let temp_dir = tempdir().unwrap();
1712 let base_path = temp_dir.path();
1713
1714 let content = r#"
1715# Test Document
1716
1717[Contact](user@example.com)
1718[Steering](steering@kubernetes.io)
1719[Support](john.doe+filter@company.co.uk)
1720[User](user_name@sub.domain.com)
1721"#;
1722
1723 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1724 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1725 let result = rule.check(&ctx).unwrap();
1726
1727 assert!(
1729 result.is_empty(),
1730 "Email addresses should be skipped. Got warnings: {result:?}"
1731 );
1732 }
1733
1734 #[test]
1735 fn test_email_addresses_vs_file_paths() {
1736 let temp_dir = tempdir().unwrap();
1739 let base_path = temp_dir.path();
1740
1741 let content = r#"
1742# Test Document
1743
1744[Email](user@example.com) <!-- Should be skipped (email) -->
1745[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
1746[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
1747"#;
1748
1749 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1751 let result = rule.check(&ctx).unwrap();
1752
1753 assert!(
1755 result.is_empty(),
1756 "All email addresses should be skipped. Got: {result:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn test_diagnostic_position_accuracy() {
1762 let temp_dir = tempdir().unwrap();
1764 let base_path = temp_dir.path();
1765
1766 let content = "prefix [text](missing.md) suffix";
1769 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1773 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1774 let result = rule.check(&ctx).unwrap();
1775
1776 assert_eq!(result.len(), 1, "Should have exactly one warning");
1777 assert_eq!(result[0].line, 1, "Should be on line 1");
1778 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
1779 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
1780 }
1781
1782 #[test]
1783 fn test_diagnostic_position_angle_brackets() {
1784 let temp_dir = tempdir().unwrap();
1786 let base_path = temp_dir.path();
1787
1788 let content = "[link](<missing.md>)";
1791 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1794 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1795 let result = rule.check(&ctx).unwrap();
1796
1797 assert_eq!(result.len(), 1, "Should have exactly one warning");
1798 assert_eq!(result[0].line, 1, "Should be on line 1");
1799 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
1800 }
1801
1802 #[test]
1803 fn test_diagnostic_position_multiline() {
1804 let temp_dir = tempdir().unwrap();
1806 let base_path = temp_dir.path();
1807
1808 let content = r#"# Title
1809Some text on line 2
1810[link on line 3](missing1.md)
1811More text
1812[link on line 5](missing2.md)"#;
1813
1814 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1815 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1816 let result = rule.check(&ctx).unwrap();
1817
1818 assert_eq!(result.len(), 2, "Should have two warnings");
1819
1820 assert_eq!(result[0].line, 3, "First warning should be on line 3");
1822 assert!(result[0].message.contains("missing1.md"));
1823
1824 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
1826 assert!(result[1].message.contains("missing2.md"));
1827 }
1828
1829 #[test]
1830 fn test_diagnostic_position_with_spaces() {
1831 let temp_dir = tempdir().unwrap();
1833 let base_path = temp_dir.path();
1834
1835 let content = "[link]( missing.md )";
1836 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1842 let result = rule.check(&ctx).unwrap();
1843
1844 assert_eq!(result.len(), 1, "Should have exactly one warning");
1845 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
1847 }
1848
1849 #[test]
1850 fn test_diagnostic_position_image() {
1851 let temp_dir = tempdir().unwrap();
1853 let base_path = temp_dir.path();
1854
1855 let content = "";
1856
1857 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1859 let result = rule.check(&ctx).unwrap();
1860
1861 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
1862 assert_eq!(result[0].line, 1);
1863 assert!(result[0].column > 0, "Should have valid column position");
1865 assert!(result[0].message.contains("missing.jpg"));
1866 }
1867
1868 #[test]
1869 fn test_wikilinks_skipped() {
1870 let temp_dir = tempdir().unwrap();
1873 let base_path = temp_dir.path();
1874
1875 let content = r#"# Test Document
1876
1877[[Microsoft#Windows OS]]
1878[[SomePage]]
1879[[Page With Spaces]]
1880[[path/to/page#section]]
1881[[page|Display Text]]
1882
1883This is a [real missing link](missing.md) that should be flagged.
1884"#;
1885
1886 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let result = rule.check(&ctx).unwrap();
1889
1890 assert_eq!(
1892 result.len(),
1893 1,
1894 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
1895 );
1896 assert!(
1897 result[0].message.contains("missing.md"),
1898 "Warning should be for missing.md, not wikilinks"
1899 );
1900 }
1901
1902 #[test]
1903 fn test_wikilinks_not_added_to_index() {
1904 let temp_dir = tempdir().unwrap();
1906 let base_path = temp_dir.path();
1907
1908 let content = r#"# Test Document
1909
1910[[Microsoft#Windows OS]]
1911[[SomePage#section]]
1912[Regular Link](other.md)
1913"#;
1914
1915 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1916 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1917
1918 let mut file_index = FileIndex::new();
1919 rule.contribute_to_index(&ctx, &mut file_index);
1920
1921 let cross_file_links = &file_index.cross_file_links;
1924 assert_eq!(
1925 cross_file_links.len(),
1926 1,
1927 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
1928 );
1929 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
1930 }
1931}