1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::workspace_index::{FileIndex, extract_cross_file_links};
10use regex::Regex;
11use std::collections::HashMap;
12use std::env;
13use std::path::{Path, PathBuf};
14use std::sync::LazyLock;
15use std::sync::{Arc, Mutex};
16
17mod md057_config;
18use crate::rule_config_serde::RuleConfig;
19use crate::utils::mkdocs_config::resolve_docs_dir;
20use crate::utils::obsidian_config::resolve_attachment_folder;
21pub use md057_config::{AbsoluteLinksOption, MD057Config};
22
23static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
25 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
26
27fn reset_file_existence_cache() {
29 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
30 cache.clear();
31 }
32}
33
34fn file_exists_with_cache(path: &Path) -> bool {
36 match FILE_EXISTENCE_CACHE.lock() {
37 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
38 Err(_) => path.exists(), }
40}
41
42fn file_exists_or_markdown_extension(path: &Path) -> bool {
45 if file_exists_with_cache(path) {
47 return true;
48 }
49
50 if path.extension().is_none() {
52 for ext in MARKDOWN_EXTENSIONS {
53 let path_with_ext = path.with_extension(&ext[1..]);
55 if file_exists_with_cache(&path_with_ext) {
56 return true;
57 }
58 }
59 }
60
61 false
62}
63
64static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
66
67static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
71 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
72
73static URL_EXTRACT_REGEX: LazyLock<Regex> =
76 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
77
78static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
82 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
83
84static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
86
87#[inline]
90fn hex_digit_to_value(byte: u8) -> Option<u8> {
91 match byte {
92 b'0'..=b'9' => Some(byte - b'0'),
93 b'a'..=b'f' => Some(byte - b'a' + 10),
94 b'A'..=b'F' => Some(byte - b'A' + 10),
95 _ => None,
96 }
97}
98
99const MARKDOWN_EXTENSIONS: &[&str] = &[
101 ".md",
102 ".markdown",
103 ".mdx",
104 ".mkd",
105 ".mkdn",
106 ".mdown",
107 ".mdwn",
108 ".qmd",
109 ".rmd",
110];
111
112#[derive(Debug, Clone)]
114pub struct MD057ExistingRelativeLinks {
115 base_path: Arc<Mutex<Option<PathBuf>>>,
117 config: MD057Config,
119 flavor: crate::config::MarkdownFlavor,
121}
122
123impl Default for MD057ExistingRelativeLinks {
124 fn default() -> Self {
125 Self {
126 base_path: Arc::new(Mutex::new(None)),
127 config: MD057Config::default(),
128 flavor: crate::config::MarkdownFlavor::default(),
129 }
130 }
131}
132
133impl MD057ExistingRelativeLinks {
134 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
141 let path = path.as_ref();
142 let dir_path = if path.is_file() {
143 path.parent().map(std::path::Path::to_path_buf)
144 } else {
145 Some(path.to_path_buf())
146 };
147
148 if let Ok(mut guard) = self.base_path.lock() {
149 *guard = dir_path;
150 }
151 self
152 }
153
154 pub fn from_config_struct(config: MD057Config) -> Self {
155 Self {
156 base_path: Arc::new(Mutex::new(None)),
157 config,
158 flavor: crate::config::MarkdownFlavor::default(),
159 }
160 }
161
162 #[cfg(test)]
164 fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
165 self.flavor = flavor;
166 self
167 }
168
169 #[inline]
181 fn is_external_url(&self, url: &str) -> bool {
182 if url.is_empty() {
183 return false;
184 }
185
186 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
188 return true;
189 }
190
191 if url.starts_with("{{") || url.starts_with("{%") {
194 return true;
195 }
196
197 if url.contains('@') {
200 return true; }
202
203 if url.ends_with(".com") {
210 return true;
211 }
212
213 if url.starts_with('~') || url.starts_with('@') {
217 return true;
218 }
219
220 false
222 }
223
224 #[inline]
226 fn is_fragment_only_link(&self, url: &str) -> bool {
227 url.starts_with('#')
228 }
229
230 #[inline]
233 fn is_absolute_path(url: &str) -> bool {
234 url.starts_with('/')
235 }
236
237 fn url_decode(path: &str) -> String {
241 if !path.contains('%') {
243 return path.to_string();
244 }
245
246 let bytes = path.as_bytes();
247 let mut result = Vec::with_capacity(bytes.len());
248 let mut i = 0;
249
250 while i < bytes.len() {
251 if bytes[i] == b'%' && i + 2 < bytes.len() {
252 let hex1 = bytes[i + 1];
254 let hex2 = bytes[i + 2];
255 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
256 result.push(d1 * 16 + d2);
257 i += 3;
258 continue;
259 }
260 }
261 result.push(bytes[i]);
262 i += 1;
263 }
264
265 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
267 }
268
269 fn strip_query_and_fragment(url: &str) -> &str {
277 let query_pos = url.find('?');
280 let fragment_pos = url.find('#');
281
282 match (query_pos, fragment_pos) {
283 (Some(q), Some(f)) => {
284 &url[..q.min(f)]
286 }
287 (Some(q), None) => &url[..q],
288 (None, Some(f)) => &url[..f],
289 (None, None) => url,
290 }
291 }
292
293 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
295 base_path.join(link)
296 }
297
298 fn compute_search_paths(
303 &self,
304 flavor: crate::config::MarkdownFlavor,
305 source_file: Option<&Path>,
306 base_path: &Path,
307 ) -> Vec<PathBuf> {
308 let mut paths = Vec::new();
309
310 if flavor == crate::config::MarkdownFlavor::Obsidian
312 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
313 && attachment_dir != *base_path
314 {
315 paths.push(attachment_dir);
316 }
317
318 for search_path in &self.config.search_paths {
320 let resolved = if Path::new(search_path).is_absolute() {
321 PathBuf::from(search_path)
322 } else {
323 CURRENT_DIR.join(search_path)
325 };
326 if resolved != *base_path && !paths.contains(&resolved) {
327 paths.push(resolved);
328 }
329 }
330
331 paths
332 }
333
334 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
336 search_paths.iter().any(|dir| {
337 let candidate = dir.join(decoded_path);
338 file_exists_or_markdown_extension(&candidate)
339 })
340 }
341
342 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
348 if !self.config.compact_paths {
349 return None;
350 }
351
352 let path_end = url
354 .find('?')
355 .unwrap_or(url.len())
356 .min(url.find('#').unwrap_or(url.len()));
357 let path_part = &url[..path_end];
358 let suffix = &url[path_end..];
359
360 let decoded_path = Self::url_decode(path_part);
362
363 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
364 }
365
366 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
371 let Some(docs_dir) = resolve_docs_dir(source_path) else {
372 return Some(format!(
374 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
375 ));
376 };
377
378 let relative_url = url.trim_start_matches('/');
380
381 let file_path = Self::strip_query_and_fragment(relative_url);
383 let decoded = Self::url_decode(file_path);
384 let resolved_path = docs_dir.join(&decoded);
385
386 let is_directory_link = url.ends_with('/') || decoded.is_empty();
391 if is_directory_link || resolved_path.is_dir() {
392 let index_path = resolved_path.join("index.md");
393 if file_exists_with_cache(&index_path) {
394 return None; }
396 if resolved_path.is_dir() {
398 return Some(format!(
399 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
400 resolved_path.display()
401 ));
402 }
403 }
404
405 if file_exists_or_markdown_extension(&resolved_path) {
407 return None; }
409
410 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
412 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
413 && let (Some(stem), Some(parent)) = (
414 resolved_path.file_stem().and_then(|s| s.to_str()),
415 resolved_path.parent(),
416 )
417 {
418 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
419 let source_path = parent.join(format!("{stem}{md_ext}"));
420 file_exists_with_cache(&source_path)
421 });
422 if has_md_source {
423 return None; }
425 }
426
427 Some(format!(
428 "Absolute link '{url}' resolves to '{}' which does not exist",
429 resolved_path.display()
430 ))
431 }
432
433 fn validate_absolute_link_via_roots(url: &str, roots: &[String]) -> Option<String> {
442 if roots.is_empty() {
443 return Some(format!(
444 "Absolute link '{url}' cannot be validated locally (no roots configured)"
445 ));
446 }
447
448 let relative_url = url.trim_start_matches('/');
449 let file_path = Self::strip_query_and_fragment(relative_url);
450 let decoded = Self::url_decode(file_path);
451 let is_directory_link = url.ends_with('/') || decoded.is_empty();
452
453 for root in roots {
454 let root_path = if Path::new(root).is_absolute() {
455 PathBuf::from(root)
456 } else {
457 CURRENT_DIR.join(root)
458 };
459 let resolved = root_path.join(&decoded);
460
461 let is_dir = resolved.is_dir();
465 if is_directory_link || is_dir {
466 let index_path = resolved.join("index.md");
467 if file_exists_with_cache(&index_path) {
468 return None;
469 }
470 if is_dir {
474 continue;
475 }
476 }
477
478 if file_exists_or_markdown_extension(&resolved) {
479 return None;
480 }
481
482 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
485 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
486 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
487 {
488 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
489 let source_path = parent.join(format!("{stem}{md_ext}"));
490 file_exists_with_cache(&source_path)
491 });
492 if has_md_source {
493 return None;
494 }
495 }
496 }
497
498 Some(format!("Absolute link '{url}' was not found under any configured root"))
499 }
500}
501
502impl Rule for MD057ExistingRelativeLinks {
503 fn name(&self) -> &'static str {
504 "MD057"
505 }
506
507 fn description(&self) -> &'static str {
508 "Relative links should point to existing files"
509 }
510
511 fn category(&self) -> RuleCategory {
512 RuleCategory::Link
513 }
514
515 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
516 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
517 }
518
519 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
520 let content = ctx.content;
521
522 if content.is_empty() || !content.contains('[') {
524 return Ok(Vec::new());
525 }
526
527 if !content.contains("](") && !content.contains("]:") {
530 return Ok(Vec::new());
531 }
532
533 reset_file_existence_cache();
535
536 let mut warnings = Vec::new();
537
538 let base_path: Option<PathBuf> = {
542 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
544 if explicit_base.is_some() {
545 explicit_base
546 } else if let Some(ref source_file) = ctx.source_file {
547 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
551 resolved_file
552 .parent()
553 .map(std::path::Path::to_path_buf)
554 .or_else(|| Some(CURRENT_DIR.clone()))
555 } else {
556 None
558 }
559 };
560
561 let Some(base_path) = base_path else {
563 return Ok(warnings);
564 };
565
566 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path);
568
569 if !ctx.links.is_empty() {
571 let line_index = &ctx.line_index;
573
574 let lines = ctx.raw_lines();
576
577 let mut processed_lines = std::collections::HashSet::new();
580
581 for link in &ctx.links {
582 let line_idx = link.line - 1;
583 if line_idx >= lines.len() {
584 continue;
585 }
586
587 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
589 continue;
590 }
591
592 if !processed_lines.insert(line_idx) {
594 continue;
595 }
596
597 let line = lines[line_idx];
598
599 if !line.contains("](") {
601 continue;
602 }
603
604 for link_match in LINK_START_REGEX.find_iter(line) {
606 let start_pos = link_match.start();
607 let end_pos = link_match.end();
608
609 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
611 let absolute_start_pos = line_start_byte + start_pos;
612
613 if ctx.is_in_code_span_byte(absolute_start_pos) {
615 continue;
616 }
617
618 if ctx.is_in_math_span(absolute_start_pos) {
620 continue;
621 }
622
623 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
627 .captures_at(line, end_pos - 1)
628 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
629 .or_else(|| {
630 URL_EXTRACT_REGEX
631 .captures_at(line, end_pos - 1)
632 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
633 });
634
635 if let Some((caps, url_group)) = caps_and_url {
636 let url = url_group.as_str().trim();
637
638 if url.is_empty() {
640 continue;
641 }
642
643 if url.starts_with('`') && url.ends_with('`') {
647 continue;
648 }
649
650 if self.is_external_url(url) || self.is_fragment_only_link(url) {
652 continue;
653 }
654
655 if Self::is_absolute_path(url) {
657 match self.config.absolute_links {
658 AbsoluteLinksOption::Warn => {
659 let url_start = url_group.start();
660 let url_end = url_group.end();
661 warnings.push(LintWarning {
662 rule_name: Some(self.name().to_string()),
663 line: link.line,
664 column: url_start + 1,
665 end_line: link.line,
666 end_column: url_end + 1,
667 message: format!("Absolute link '{url}' cannot be validated locally"),
668 severity: Severity::Warning,
669 fix: None,
670 });
671 }
672 AbsoluteLinksOption::RelativeToDocs => {
673 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
674 let url_start = url_group.start();
675 let url_end = url_group.end();
676 warnings.push(LintWarning {
677 rule_name: Some(self.name().to_string()),
678 line: link.line,
679 column: url_start + 1,
680 end_line: link.line,
681 end_column: url_end + 1,
682 message: msg,
683 severity: Severity::Warning,
684 fix: None,
685 });
686 }
687 }
688 AbsoluteLinksOption::RelativeToRoots => {
689 if let Some(msg) = Self::validate_absolute_link_via_roots(url, &self.config.roots) {
690 let url_start = url_group.start();
691 let url_end = url_group.end();
692 warnings.push(LintWarning {
693 rule_name: Some(self.name().to_string()),
694 line: link.line,
695 column: url_start + 1,
696 end_line: link.line,
697 end_column: url_end + 1,
698 message: msg,
699 severity: Severity::Warning,
700 fix: None,
701 });
702 }
703 }
704 AbsoluteLinksOption::Ignore => {}
705 }
706 continue;
707 }
708
709 let full_url_for_compact = if let Some(frag) = caps.get(2) {
713 format!("{url}{}", frag.as_str())
714 } else {
715 url.to_string()
716 };
717 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
718 let url_start = url_group.start();
719 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
720 let fix_byte_start = line_start_byte + url_start;
721 let fix_byte_end = line_start_byte + url_end;
722 warnings.push(LintWarning {
723 rule_name: Some(self.name().to_string()),
724 line: link.line,
725 column: url_start + 1,
726 end_line: link.line,
727 end_column: url_end + 1,
728 message: format!(
729 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
730 ),
731 severity: Severity::Warning,
732 fix: Some(Fix {
733 range: fix_byte_start..fix_byte_end,
734 replacement: suggestion,
735 }),
736 });
737 }
738
739 let file_path = Self::strip_query_and_fragment(url);
741
742 let decoded_path = Self::url_decode(file_path);
744
745 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
747
748 if file_exists_or_markdown_extension(&resolved_path) {
750 continue; }
752
753 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
755 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
756 && let (Some(stem), Some(parent)) = (
757 resolved_path.file_stem().and_then(|s| s.to_str()),
758 resolved_path.parent(),
759 ) {
760 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
761 let source_path = parent.join(format!("{stem}{md_ext}"));
762 file_exists_with_cache(&source_path)
763 })
764 } else {
765 false
766 };
767
768 if has_md_source {
769 continue; }
771
772 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
774 continue;
775 }
776
777 let url_start = url_group.start();
781 let url_end = url_group.end();
782
783 warnings.push(LintWarning {
784 rule_name: Some(self.name().to_string()),
785 line: link.line,
786 column: url_start + 1, end_line: link.line,
788 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
790 severity: Severity::Error,
791 fix: None,
792 });
793 }
794 }
795 }
796 }
797
798 for image in &ctx.images {
800 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
802 continue;
803 }
804
805 let url = image.url.as_ref();
806
807 if url.is_empty() {
809 continue;
810 }
811
812 if self.is_external_url(url) || self.is_fragment_only_link(url) {
814 continue;
815 }
816
817 if Self::is_absolute_path(url) {
819 match self.config.absolute_links {
820 AbsoluteLinksOption::Warn => {
821 warnings.push(LintWarning {
822 rule_name: Some(self.name().to_string()),
823 line: image.line,
824 column: image.start_col + 1,
825 end_line: image.line,
826 end_column: image.start_col + 1 + url.len(),
827 message: format!("Absolute link '{url}' cannot be validated locally"),
828 severity: Severity::Warning,
829 fix: None,
830 });
831 }
832 AbsoluteLinksOption::RelativeToDocs => {
833 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
834 warnings.push(LintWarning {
835 rule_name: Some(self.name().to_string()),
836 line: image.line,
837 column: image.start_col + 1,
838 end_line: image.line,
839 end_column: image.start_col + 1 + url.len(),
840 message: msg,
841 severity: Severity::Warning,
842 fix: None,
843 });
844 }
845 }
846 AbsoluteLinksOption::RelativeToRoots => {
847 if let Some(msg) = Self::validate_absolute_link_via_roots(url, &self.config.roots) {
848 warnings.push(LintWarning {
849 rule_name: Some(self.name().to_string()),
850 line: image.line,
851 column: image.start_col + 1,
852 end_line: image.line,
853 end_column: image.start_col + 1 + url.len(),
854 message: msg,
855 severity: Severity::Warning,
856 fix: None,
857 });
858 }
859 }
860 AbsoluteLinksOption::Ignore => {}
861 }
862 continue;
863 }
864
865 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
867 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
870 let fix_byte_start = image.byte_offset + url_offset;
871 let fix_byte_end = fix_byte_start + url.len();
872 Fix {
873 range: fix_byte_start..fix_byte_end,
874 replacement: suggestion.clone(),
875 }
876 });
877
878 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
879 let url_col = fix
880 .as_ref()
881 .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
882 warnings.push(LintWarning {
883 rule_name: Some(self.name().to_string()),
884 line: image.line,
885 column: url_col,
886 end_line: image.line,
887 end_column: url_col + url.len(),
888 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
889 severity: Severity::Warning,
890 fix,
891 });
892 }
893
894 let file_path = Self::strip_query_and_fragment(url);
896
897 let decoded_path = Self::url_decode(file_path);
899
900 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
902
903 if file_exists_or_markdown_extension(&resolved_path) {
905 continue; }
907
908 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
910 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
911 && let (Some(stem), Some(parent)) = (
912 resolved_path.file_stem().and_then(|s| s.to_str()),
913 resolved_path.parent(),
914 ) {
915 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
916 let source_path = parent.join(format!("{stem}{md_ext}"));
917 file_exists_with_cache(&source_path)
918 })
919 } else {
920 false
921 };
922
923 if has_md_source {
924 continue; }
926
927 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
929 continue;
930 }
931
932 warnings.push(LintWarning {
935 rule_name: Some(self.name().to_string()),
936 line: image.line,
937 column: image.start_col + 1,
938 end_line: image.line,
939 end_column: image.start_col + 1 + url.len(),
940 message: format!("Relative link '{url}' does not exist"),
941 severity: Severity::Error,
942 fix: None,
943 });
944 }
945
946 for ref_def in &ctx.reference_defs {
948 let url = &ref_def.url;
949
950 if url.is_empty() {
952 continue;
953 }
954
955 if self.is_external_url(url) || self.is_fragment_only_link(url) {
957 continue;
958 }
959
960 if Self::is_absolute_path(url) {
962 match self.config.absolute_links {
963 AbsoluteLinksOption::Warn => {
964 let line_idx = ref_def.line - 1;
965 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
966 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
967 });
968 warnings.push(LintWarning {
969 rule_name: Some(self.name().to_string()),
970 line: ref_def.line,
971 column,
972 end_line: ref_def.line,
973 end_column: column + url.len(),
974 message: format!("Absolute link '{url}' cannot be validated locally"),
975 severity: Severity::Warning,
976 fix: None,
977 });
978 }
979 AbsoluteLinksOption::RelativeToDocs => {
980 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
981 let line_idx = ref_def.line - 1;
982 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
983 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
984 });
985 warnings.push(LintWarning {
986 rule_name: Some(self.name().to_string()),
987 line: ref_def.line,
988 column,
989 end_line: ref_def.line,
990 end_column: column + url.len(),
991 message: msg,
992 severity: Severity::Warning,
993 fix: None,
994 });
995 }
996 }
997 AbsoluteLinksOption::RelativeToRoots => {
998 if let Some(msg) = Self::validate_absolute_link_via_roots(url, &self.config.roots) {
999 let line_idx = ref_def.line - 1;
1000 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1001 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1002 });
1003 warnings.push(LintWarning {
1004 rule_name: Some(self.name().to_string()),
1005 line: ref_def.line,
1006 column,
1007 end_line: ref_def.line,
1008 end_column: column + url.len(),
1009 message: msg,
1010 severity: Severity::Warning,
1011 fix: None,
1012 });
1013 }
1014 }
1015 AbsoluteLinksOption::Ignore => {}
1016 }
1017 continue;
1018 }
1019
1020 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1022 let ref_line_idx = ref_def.line - 1;
1023 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
1024 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1025 });
1026 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1027 let fix_byte_start = ref_line_start_byte + col - 1;
1028 let fix_byte_end = fix_byte_start + url.len();
1029 warnings.push(LintWarning {
1030 rule_name: Some(self.name().to_string()),
1031 line: ref_def.line,
1032 column: col,
1033 end_line: ref_def.line,
1034 end_column: col + url.len(),
1035 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1036 severity: Severity::Warning,
1037 fix: Some(Fix {
1038 range: fix_byte_start..fix_byte_end,
1039 replacement: suggestion,
1040 }),
1041 });
1042 }
1043
1044 let file_path = Self::strip_query_and_fragment(url);
1046
1047 let decoded_path = Self::url_decode(file_path);
1049
1050 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1052
1053 if file_exists_or_markdown_extension(&resolved_path) {
1055 continue; }
1057
1058 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1060 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1061 && let (Some(stem), Some(parent)) = (
1062 resolved_path.file_stem().and_then(|s| s.to_str()),
1063 resolved_path.parent(),
1064 ) {
1065 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1066 let source_path = parent.join(format!("{stem}{md_ext}"));
1067 file_exists_with_cache(&source_path)
1068 })
1069 } else {
1070 false
1071 };
1072
1073 if has_md_source {
1074 continue; }
1076
1077 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1079 continue;
1080 }
1081
1082 let line_idx = ref_def.line - 1;
1085 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1086 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1088 });
1089
1090 warnings.push(LintWarning {
1091 rule_name: Some(self.name().to_string()),
1092 line: ref_def.line,
1093 column,
1094 end_line: ref_def.line,
1095 end_column: column + url.len(),
1096 message: format!("Relative link '{url}' does not exist"),
1097 severity: Severity::Error,
1098 fix: None,
1099 });
1100 }
1101
1102 Ok(warnings)
1103 }
1104
1105 fn fix_capability(&self) -> FixCapability {
1106 if self.config.compact_paths {
1107 FixCapability::ConditionallyFixable
1108 } else {
1109 FixCapability::Unfixable
1110 }
1111 }
1112
1113 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1114 if !self.config.compact_paths {
1115 return Ok(ctx.content.to_string());
1116 }
1117
1118 let warnings = self.check(ctx)?;
1119 let warnings =
1120 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1121 let mut content = ctx.content.to_string();
1122
1123 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1125 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
1126
1127 for fix in fixes {
1128 if fix.range.end <= content.len() {
1129 content.replace_range(fix.range.clone(), &fix.replacement);
1130 }
1131 }
1132
1133 Ok(content)
1134 }
1135
1136 fn as_any(&self) -> &dyn std::any::Any {
1137 self
1138 }
1139
1140 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1141 let default_config = MD057Config::default();
1142 let json_value = serde_json::to_value(&default_config).ok()?;
1143 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1144
1145 if let toml::Value::Table(table) = toml_value {
1146 if !table.is_empty() {
1147 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1148 } else {
1149 None
1150 }
1151 } else {
1152 None
1153 }
1154 }
1155
1156 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1157 where
1158 Self: Sized,
1159 {
1160 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1161 let mut rule = Self::from_config_struct(rule_config);
1162 rule.flavor = config.global.flavor;
1163 Box::new(rule)
1164 }
1165
1166 fn cross_file_scope(&self) -> CrossFileScope {
1167 CrossFileScope::Workspace
1168 }
1169
1170 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1171 for link in extract_cross_file_links(ctx) {
1174 index.add_cross_file_link(link);
1175 }
1176 }
1177
1178 fn cross_file_check(
1179 &self,
1180 file_path: &Path,
1181 file_index: &FileIndex,
1182 workspace_index: &crate::workspace_index::WorkspaceIndex,
1183 ) -> LintResult {
1184 reset_file_existence_cache();
1186
1187 let mut warnings = Vec::new();
1188
1189 let file_dir = file_path.parent();
1191
1192 let base_path = file_dir.map_or_else(|| CURRENT_DIR.clone(), std::path::Path::to_path_buf);
1194 let extra_search_paths = self.compute_search_paths(self.flavor, Some(file_path), &base_path);
1195
1196 for cross_link in &file_index.cross_file_links {
1197 let decoded_target = Self::url_decode(&cross_link.target_path);
1200
1201 if decoded_target.starts_with('/') {
1205 continue;
1206 }
1207
1208 let target_path = if let Some(dir) = file_dir {
1210 dir.join(&decoded_target)
1211 } else {
1212 Path::new(&decoded_target).to_path_buf()
1213 };
1214
1215 let target_path = normalize_path(&target_path);
1217
1218 let file_exists =
1220 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1221
1222 if !file_exists {
1223 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1226 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1227 && let (Some(stem), Some(parent)) =
1228 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1229 {
1230 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1231 let source_path = parent.join(format!("{stem}{md_ext}"));
1232 workspace_index.contains_file(&source_path) || source_path.exists()
1233 })
1234 } else {
1235 false
1236 };
1237
1238 if !has_md_source && !Self::exists_in_search_paths(&decoded_target, &extra_search_paths) {
1239 warnings.push(LintWarning {
1240 rule_name: Some(self.name().to_string()),
1241 line: cross_link.line,
1242 column: cross_link.column,
1243 end_line: cross_link.line,
1244 end_column: cross_link.column + cross_link.target_path.len(),
1245 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1246 severity: Severity::Error,
1247 fix: None,
1248 });
1249 }
1250 }
1251 }
1252
1253 Ok(warnings)
1254 }
1255}
1256
1257fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1262 let from_components: Vec<_> = from_dir.components().collect();
1263 let to_components: Vec<_> = to_path.components().collect();
1264
1265 let common_len = from_components
1267 .iter()
1268 .zip(to_components.iter())
1269 .take_while(|(a, b)| a == b)
1270 .count();
1271
1272 let mut result = PathBuf::new();
1273
1274 for _ in common_len..from_components.len() {
1276 result.push("..");
1277 }
1278
1279 for component in &to_components[common_len..] {
1281 result.push(component);
1282 }
1283
1284 result
1285}
1286
1287fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1293 let link_path = Path::new(raw_link_path);
1294
1295 let has_traversal = link_path
1297 .components()
1298 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1299
1300 if !has_traversal {
1301 return None;
1302 }
1303
1304 let combined = source_dir.join(link_path);
1306 let normalized_target = normalize_path(&combined);
1307
1308 let normalized_source = normalize_path(source_dir);
1310 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1311
1312 if shortest != link_path {
1314 let compact = shortest.to_string_lossy().to_string();
1315 if compact.is_empty() {
1317 return None;
1318 }
1319 Some(compact.replace('\\', "/"))
1321 } else {
1322 None
1323 }
1324}
1325
1326fn normalize_path(path: &Path) -> PathBuf {
1328 let mut components = Vec::new();
1329
1330 for component in path.components() {
1331 match component {
1332 std::path::Component::ParentDir => {
1333 if !components.is_empty() {
1335 components.pop();
1336 }
1337 }
1338 std::path::Component::CurDir => {
1339 }
1341 _ => {
1342 components.push(component);
1343 }
1344 }
1345 }
1346
1347 components.iter().collect()
1348}
1349
1350#[cfg(test)]
1351mod tests {
1352 use super::*;
1353 use crate::workspace_index::CrossFileLinkIndex;
1354 use std::fs::File;
1355 use std::io::Write;
1356 use tempfile::tempdir;
1357
1358 #[test]
1359 fn test_strip_query_and_fragment() {
1360 assert_eq!(
1362 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1363 "file.png"
1364 );
1365 assert_eq!(
1366 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1367 "file.png"
1368 );
1369 assert_eq!(
1370 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1371 "file.png"
1372 );
1373
1374 assert_eq!(
1376 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1377 "file.md"
1378 );
1379 assert_eq!(
1380 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1381 "file.md"
1382 );
1383
1384 assert_eq!(
1386 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1387 "file.md"
1388 );
1389
1390 assert_eq!(
1392 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1393 "file.png"
1394 );
1395
1396 assert_eq!(
1398 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1399 "path/to/image.png"
1400 );
1401 assert_eq!(
1402 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1403 "path/to/image.png"
1404 );
1405
1406 assert_eq!(
1408 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1409 "file.md"
1410 );
1411 }
1412
1413 #[test]
1414 fn test_url_decode() {
1415 assert_eq!(
1417 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1418 "penguin with space.jpg"
1419 );
1420
1421 assert_eq!(
1423 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1424 "assets/my file name.png"
1425 );
1426
1427 assert_eq!(
1429 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1430 "hello world!.md"
1431 );
1432
1433 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1435
1436 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1438
1439 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1441
1442 assert_eq!(
1444 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1445 "normal-file.md"
1446 );
1447
1448 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1450
1451 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1453
1454 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1456
1457 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1459
1460 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1462
1463 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1465
1466 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1468
1469 assert_eq!(
1471 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1472 "path/to/file.md"
1473 );
1474
1475 assert_eq!(
1477 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1478 "hello world/foo bar.md"
1479 );
1480
1481 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1483
1484 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1486 }
1487
1488 #[test]
1489 fn test_url_encoded_filenames() {
1490 let temp_dir = tempdir().unwrap();
1492 let base_path = temp_dir.path();
1493
1494 let file_with_spaces = base_path.join("penguin with space.jpg");
1496 File::create(&file_with_spaces)
1497 .unwrap()
1498 .write_all(b"image data")
1499 .unwrap();
1500
1501 let subdir = base_path.join("my images");
1503 std::fs::create_dir(&subdir).unwrap();
1504 let nested_file = subdir.join("photo 1.png");
1505 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1506
1507 let content = r#"
1509# Test Document with URL-Encoded Links
1510
1511
1512
1513
1514"#;
1515
1516 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1517
1518 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1519 let result = rule.check(&ctx).unwrap();
1520
1521 assert_eq!(
1523 result.len(),
1524 1,
1525 "Should only warn about missing%20file.jpg. Got: {result:?}"
1526 );
1527 assert!(
1528 result[0].message.contains("missing%20file.jpg"),
1529 "Warning should mention the URL-encoded filename"
1530 );
1531 }
1532
1533 #[test]
1534 fn test_external_urls() {
1535 let rule = MD057ExistingRelativeLinks::new();
1536
1537 assert!(rule.is_external_url("https://example.com"));
1539 assert!(rule.is_external_url("http://example.com"));
1540 assert!(rule.is_external_url("ftp://example.com"));
1541 assert!(rule.is_external_url("www.example.com"));
1542 assert!(rule.is_external_url("example.com"));
1543
1544 assert!(rule.is_external_url("file:///path/to/file"));
1546 assert!(rule.is_external_url("smb://server/share"));
1547 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1548 assert!(rule.is_external_url("mailto:user@example.com"));
1549 assert!(rule.is_external_url("tel:+1234567890"));
1550 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1551 assert!(rule.is_external_url("javascript:void(0)"));
1552 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1553 assert!(rule.is_external_url("git://github.com/repo.git"));
1554
1555 assert!(rule.is_external_url("user@example.com"));
1558 assert!(rule.is_external_url("steering@kubernetes.io"));
1559 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1560 assert!(rule.is_external_url("user_name@sub.domain.com"));
1561 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1562
1563 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"));
1574 assert!(!rule.is_external_url("/blog/2024/release.html"));
1575 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1576 assert!(!rule.is_external_url("/pkg/runtime"));
1577 assert!(!rule.is_external_url("/doc/go1compat"));
1578 assert!(!rule.is_external_url("/index.html"));
1579 assert!(!rule.is_external_url("/assets/logo.png"));
1580
1581 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1583 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1584 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1585 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1586 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1587
1588 assert!(rule.is_external_url("~/assets/image.png"));
1591 assert!(rule.is_external_url("~/components/Button.vue"));
1592 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1596 assert!(rule.is_external_url("@images/photo.jpg"));
1597 assert!(rule.is_external_url("@assets/styles.css"));
1598
1599 assert!(!rule.is_external_url("./relative/path.md"));
1601 assert!(!rule.is_external_url("relative/path.md"));
1602 assert!(!rule.is_external_url("../parent/path.md"));
1603 }
1604
1605 #[test]
1606 fn test_framework_path_aliases() {
1607 let temp_dir = tempdir().unwrap();
1609 let base_path = temp_dir.path();
1610
1611 let content = r#"
1613# Framework Path Aliases
1614
1615
1616
1617
1618
1619[Link](@/pages/about.md)
1620
1621This is a [real missing link](missing.md) that should be flagged.
1622"#;
1623
1624 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1625
1626 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1627 let result = rule.check(&ctx).unwrap();
1628
1629 assert_eq!(
1631 result.len(),
1632 1,
1633 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1634 );
1635 assert!(
1636 result[0].message.contains("missing.md"),
1637 "Warning should be for missing.md"
1638 );
1639 }
1640
1641 #[test]
1642 fn test_url_decode_security_path_traversal() {
1643 let temp_dir = tempdir().unwrap();
1646 let base_path = temp_dir.path();
1647
1648 let file_in_base = base_path.join("safe.md");
1650 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1651
1652 let content = r#"
1657[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1658[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1659[Safe link](safe.md)
1660"#;
1661
1662 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1663
1664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1665 let result = rule.check(&ctx).unwrap();
1666
1667 assert_eq!(
1670 result.len(),
1671 2,
1672 "Should have warnings for traversal attempts. Got: {result:?}"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_url_encoded_utf8_filenames() {
1678 let temp_dir = tempdir().unwrap();
1680 let base_path = temp_dir.path();
1681
1682 let cafe_file = base_path.join("café.md");
1684 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1685
1686 let content = r#"
1687[Café link](caf%C3%A9.md)
1688[Missing unicode](r%C3%A9sum%C3%A9.md)
1689"#;
1690
1691 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1692
1693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694 let result = rule.check(&ctx).unwrap();
1695
1696 assert_eq!(
1698 result.len(),
1699 1,
1700 "Should only warn about missing résumé.md. Got: {result:?}"
1701 );
1702 assert!(
1703 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1704 "Warning should mention the URL-encoded filename"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_url_encoded_emoji_filenames() {
1710 let temp_dir = tempdir().unwrap();
1713 let base_path = temp_dir.path();
1714
1715 let emoji_dir = base_path.join("👤 Personal");
1717 std::fs::create_dir(&emoji_dir).unwrap();
1718
1719 let file_path = emoji_dir.join("TV Shows.md");
1721 File::create(&file_path)
1722 .unwrap()
1723 .write_all(b"# TV Shows\n\nContent here.")
1724 .unwrap();
1725
1726 let content = r#"
1729# Test Document
1730
1731[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1732[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1733"#;
1734
1735 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1736
1737 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1738 let result = rule.check(&ctx).unwrap();
1739
1740 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1742 assert!(
1743 result[0].message.contains("Missing.md"),
1744 "Warning should be for Missing.md, got: {}",
1745 result[0].message
1746 );
1747 }
1748
1749 #[test]
1750 fn test_no_warnings_without_base_path() {
1751 let rule = MD057ExistingRelativeLinks::new();
1752 let content = "[Link](missing.md)";
1753
1754 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1755 let result = rule.check(&ctx).unwrap();
1756 assert!(result.is_empty(), "Should have no warnings without base path");
1757 }
1758
1759 #[test]
1760 fn test_existing_and_missing_links() {
1761 let temp_dir = tempdir().unwrap();
1763 let base_path = temp_dir.path();
1764
1765 let exists_path = base_path.join("exists.md");
1767 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1768
1769 assert!(exists_path.exists(), "exists.md should exist for this test");
1771
1772 let content = r#"
1774# Test Document
1775
1776[Valid Link](exists.md)
1777[Invalid Link](missing.md)
1778[External Link](https://example.com)
1779[Media Link](image.jpg)
1780 "#;
1781
1782 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1784
1785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let result = rule.check(&ctx).unwrap();
1788
1789 assert_eq!(result.len(), 2);
1791 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1792 assert!(messages.iter().any(|m| m.contains("missing.md")));
1793 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1794 }
1795
1796 #[test]
1797 fn test_angle_bracket_links() {
1798 let temp_dir = tempdir().unwrap();
1800 let base_path = temp_dir.path();
1801
1802 let exists_path = base_path.join("exists.md");
1804 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1805
1806 let content = r#"
1808# Test Document
1809
1810[Valid Link](<exists.md>)
1811[Invalid Link](<missing.md>)
1812[External Link](<https://example.com>)
1813 "#;
1814
1815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1817
1818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 1, "Should have exactly one warning");
1823 assert!(
1824 result[0].message.contains("missing.md"),
1825 "Warning should mention missing.md"
1826 );
1827 }
1828
1829 #[test]
1830 fn test_angle_bracket_links_with_parens() {
1831 let temp_dir = tempdir().unwrap();
1833 let base_path = temp_dir.path();
1834
1835 let app_dir = base_path.join("app");
1837 std::fs::create_dir(&app_dir).unwrap();
1838 let upload_dir = app_dir.join("(upload)");
1839 std::fs::create_dir(&upload_dir).unwrap();
1840 let page_file = upload_dir.join("page.tsx");
1841 File::create(&page_file)
1842 .unwrap()
1843 .write_all(b"export default function Page() {}")
1844 .unwrap();
1845
1846 let content = r#"
1848# Test Document with Paths Containing Parens
1849
1850[Upload Page](<app/(upload)/page.tsx>)
1851[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1852[Missing](<app/(missing)/file.md>)
1853"#;
1854
1855 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1856
1857 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1858 let result = rule.check(&ctx).unwrap();
1859
1860 assert_eq!(
1862 result.len(),
1863 1,
1864 "Should have exactly one warning for missing file. Got: {result:?}"
1865 );
1866 assert!(
1867 result[0].message.contains("app/(missing)/file.md"),
1868 "Warning should mention app/(missing)/file.md"
1869 );
1870 }
1871
1872 #[test]
1873 fn test_all_file_types_checked() {
1874 let temp_dir = tempdir().unwrap();
1876 let base_path = temp_dir.path();
1877
1878 let content = r#"
1880[Image Link](image.jpg)
1881[Video Link](video.mp4)
1882[Markdown Link](document.md)
1883[PDF Link](file.pdf)
1884"#;
1885
1886 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1887
1888 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1889 let result = rule.check(&ctx).unwrap();
1890
1891 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1893 }
1894
1895 #[test]
1896 fn test_code_span_detection() {
1897 let rule = MD057ExistingRelativeLinks::new();
1898
1899 let temp_dir = tempdir().unwrap();
1901 let base_path = temp_dir.path();
1902
1903 let rule = rule.with_path(base_path);
1904
1905 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1907
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 only flag the real link");
1913 assert!(result[0].message.contains("nonexistent.md"));
1914 }
1915
1916 #[test]
1917 fn test_inline_code_spans() {
1918 let temp_dir = tempdir().unwrap();
1920 let base_path = temp_dir.path();
1921
1922 let content = r#"
1924# Test Document
1925
1926This is a normal link: [Link](missing.md)
1927
1928This is a code span with a link: `[Link](another-missing.md)`
1929
1930Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1931
1932 "#;
1933
1934 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1936
1937 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1939 let result = rule.check(&ctx).unwrap();
1940
1941 assert_eq!(result.len(), 1, "Should have exactly one warning");
1943 assert!(
1944 result[0].message.contains("missing.md"),
1945 "Warning should be for missing.md"
1946 );
1947 assert!(
1948 !result.iter().any(|w| w.message.contains("another-missing.md")),
1949 "Should not warn about link in code span"
1950 );
1951 assert!(
1952 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1953 "Should not warn about link in inline code"
1954 );
1955 }
1956
1957 #[test]
1958 fn test_extensionless_link_resolution() {
1959 let temp_dir = tempdir().unwrap();
1961 let base_path = temp_dir.path();
1962
1963 let page_path = base_path.join("page.md");
1965 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1966
1967 let content = r#"
1969# Test Document
1970
1971[Link without extension](page)
1972[Link with extension](page.md)
1973[Missing link](nonexistent)
1974"#;
1975
1976 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1977
1978 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1979 let result = rule.check(&ctx).unwrap();
1980
1981 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1984 assert!(
1985 result[0].message.contains("nonexistent"),
1986 "Warning should be for 'nonexistent' not 'page'"
1987 );
1988 }
1989
1990 #[test]
1992 fn test_cross_file_scope() {
1993 let rule = MD057ExistingRelativeLinks::new();
1994 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1995 }
1996
1997 #[test]
1998 fn test_contribute_to_index_extracts_markdown_links() {
1999 let rule = MD057ExistingRelativeLinks::new();
2000 let content = r#"
2001# Document
2002
2003[Link to docs](./docs/guide.md)
2004[Link with fragment](./other.md#section)
2005[External link](https://example.com)
2006[Image link](image.png)
2007[Media file](video.mp4)
2008"#;
2009
2010 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let mut index = FileIndex::new();
2012 rule.contribute_to_index(&ctx, &mut index);
2013
2014 assert_eq!(index.cross_file_links.len(), 2);
2016
2017 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2019 assert_eq!(index.cross_file_links[0].fragment, "");
2020
2021 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2023 assert_eq!(index.cross_file_links[1].fragment, "section");
2024 }
2025
2026 #[test]
2027 fn test_contribute_to_index_skips_external_and_anchors() {
2028 let rule = MD057ExistingRelativeLinks::new();
2029 let content = r#"
2030# Document
2031
2032[External](https://example.com)
2033[Another external](http://example.org)
2034[Fragment only](#section)
2035[FTP link](ftp://files.example.com)
2036[Mail link](mailto:test@example.com)
2037[WWW link](www.example.com)
2038"#;
2039
2040 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2041 let mut index = FileIndex::new();
2042 rule.contribute_to_index(&ctx, &mut index);
2043
2044 assert_eq!(index.cross_file_links.len(), 0);
2046 }
2047
2048 #[test]
2049 fn test_cross_file_check_valid_link() {
2050 use crate::workspace_index::WorkspaceIndex;
2051
2052 let rule = MD057ExistingRelativeLinks::new();
2053
2054 let mut workspace_index = WorkspaceIndex::new();
2056 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2057
2058 let mut file_index = FileIndex::new();
2060 file_index.add_cross_file_link(CrossFileLinkIndex {
2061 target_path: "guide.md".to_string(),
2062 fragment: "".to_string(),
2063 line: 5,
2064 column: 1,
2065 });
2066
2067 let warnings = rule
2069 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2070 .unwrap();
2071
2072 assert!(warnings.is_empty());
2074 }
2075
2076 #[test]
2077 fn test_cross_file_check_missing_link() {
2078 use crate::workspace_index::WorkspaceIndex;
2079
2080 let rule = MD057ExistingRelativeLinks::new();
2081
2082 let workspace_index = WorkspaceIndex::new();
2084
2085 let mut file_index = FileIndex::new();
2087 file_index.add_cross_file_link(CrossFileLinkIndex {
2088 target_path: "missing.md".to_string(),
2089 fragment: "".to_string(),
2090 line: 5,
2091 column: 1,
2092 });
2093
2094 let warnings = rule
2096 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2097 .unwrap();
2098
2099 assert_eq!(warnings.len(), 1);
2101 assert!(warnings[0].message.contains("missing.md"));
2102 assert!(warnings[0].message.contains("does not exist"));
2103 }
2104
2105 #[test]
2106 fn test_cross_file_check_parent_path() {
2107 use crate::workspace_index::WorkspaceIndex;
2108
2109 let rule = MD057ExistingRelativeLinks::new();
2110
2111 let mut workspace_index = WorkspaceIndex::new();
2113 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2114
2115 let mut file_index = FileIndex::new();
2117 file_index.add_cross_file_link(CrossFileLinkIndex {
2118 target_path: "../readme.md".to_string(),
2119 fragment: "".to_string(),
2120 line: 5,
2121 column: 1,
2122 });
2123
2124 let warnings = rule
2126 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2127 .unwrap();
2128
2129 assert!(warnings.is_empty());
2131 }
2132
2133 #[test]
2134 fn test_cross_file_check_html_link_with_md_source() {
2135 use crate::workspace_index::WorkspaceIndex;
2138
2139 let rule = MD057ExistingRelativeLinks::new();
2140
2141 let mut workspace_index = WorkspaceIndex::new();
2143 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2144
2145 let mut file_index = FileIndex::new();
2147 file_index.add_cross_file_link(CrossFileLinkIndex {
2148 target_path: "guide.html".to_string(),
2149 fragment: "section".to_string(),
2150 line: 10,
2151 column: 5,
2152 });
2153
2154 let warnings = rule
2156 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2157 .unwrap();
2158
2159 assert!(
2161 warnings.is_empty(),
2162 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2163 );
2164 }
2165
2166 #[test]
2167 fn test_cross_file_check_html_link_without_source() {
2168 use crate::workspace_index::WorkspaceIndex;
2170
2171 let rule = MD057ExistingRelativeLinks::new();
2172
2173 let workspace_index = WorkspaceIndex::new();
2175
2176 let mut file_index = FileIndex::new();
2178 file_index.add_cross_file_link(CrossFileLinkIndex {
2179 target_path: "missing.html".to_string(),
2180 fragment: "".to_string(),
2181 line: 10,
2182 column: 5,
2183 });
2184
2185 let warnings = rule
2187 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2188 .unwrap();
2189
2190 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
2192 assert!(warnings[0].message.contains("missing.html"));
2193 }
2194
2195 #[test]
2196 fn test_normalize_path_function() {
2197 assert_eq!(
2199 normalize_path(Path::new("docs/guide.md")),
2200 PathBuf::from("docs/guide.md")
2201 );
2202
2203 assert_eq!(
2205 normalize_path(Path::new("./docs/guide.md")),
2206 PathBuf::from("docs/guide.md")
2207 );
2208
2209 assert_eq!(
2211 normalize_path(Path::new("docs/sub/../guide.md")),
2212 PathBuf::from("docs/guide.md")
2213 );
2214
2215 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2217 }
2218
2219 #[test]
2220 fn test_html_link_with_md_source() {
2221 let temp_dir = tempdir().unwrap();
2223 let base_path = temp_dir.path();
2224
2225 let md_file = base_path.join("guide.md");
2227 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2228
2229 let content = r#"
2230[Read the guide](guide.html)
2231[Also here](getting-started.html)
2232"#;
2233
2234 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2235 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2236 let result = rule.check(&ctx).unwrap();
2237
2238 assert_eq!(
2240 result.len(),
2241 1,
2242 "Should only warn about missing source. Got: {result:?}"
2243 );
2244 assert!(result[0].message.contains("getting-started.html"));
2245 }
2246
2247 #[test]
2248 fn test_htm_link_with_md_source() {
2249 let temp_dir = tempdir().unwrap();
2251 let base_path = temp_dir.path();
2252
2253 let md_file = base_path.join("page.md");
2254 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2255
2256 let content = "[Page](page.htm)";
2257
2258 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2259 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2260 let result = rule.check(&ctx).unwrap();
2261
2262 assert!(
2263 result.is_empty(),
2264 "Should not warn when .md source exists for .htm link"
2265 );
2266 }
2267
2268 #[test]
2269 fn test_html_link_finds_various_markdown_extensions() {
2270 let temp_dir = tempdir().unwrap();
2272 let base_path = temp_dir.path();
2273
2274 File::create(base_path.join("doc.md")).unwrap();
2275 File::create(base_path.join("tutorial.mdx")).unwrap();
2276 File::create(base_path.join("guide.markdown")).unwrap();
2277
2278 let content = r#"
2279[Doc](doc.html)
2280[Tutorial](tutorial.html)
2281[Guide](guide.html)
2282"#;
2283
2284 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2285 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2286 let result = rule.check(&ctx).unwrap();
2287
2288 assert!(
2289 result.is_empty(),
2290 "Should find all markdown variants as source files. Got: {result:?}"
2291 );
2292 }
2293
2294 #[test]
2295 fn test_html_link_in_subdirectory() {
2296 let temp_dir = tempdir().unwrap();
2298 let base_path = temp_dir.path();
2299
2300 let docs_dir = base_path.join("docs");
2301 std::fs::create_dir(&docs_dir).unwrap();
2302 File::create(docs_dir.join("guide.md"))
2303 .unwrap()
2304 .write_all(b"# Guide")
2305 .unwrap();
2306
2307 let content = "[Guide](docs/guide.html)";
2308
2309 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2310 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2311 let result = rule.check(&ctx).unwrap();
2312
2313 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2314 }
2315
2316 #[test]
2317 fn test_absolute_path_skipped_in_check() {
2318 let temp_dir = tempdir().unwrap();
2321 let base_path = temp_dir.path();
2322
2323 let content = r#"
2324# Test Document
2325
2326[Go Runtime](/pkg/runtime)
2327[Go Runtime with Fragment](/pkg/runtime#section)
2328[API Docs](/api/v1/users)
2329[Blog Post](/blog/2024/release.html)
2330[React Hook](/react/hooks/use-state.html)
2331"#;
2332
2333 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2334 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2335 let result = rule.check(&ctx).unwrap();
2336
2337 assert!(
2339 result.is_empty(),
2340 "Absolute paths should be skipped. Got warnings: {result:?}"
2341 );
2342 }
2343
2344 #[test]
2345 fn test_absolute_path_skipped_in_cross_file_check() {
2346 use crate::workspace_index::WorkspaceIndex;
2348
2349 let rule = MD057ExistingRelativeLinks::new();
2350
2351 let workspace_index = WorkspaceIndex::new();
2353
2354 let mut file_index = FileIndex::new();
2356 file_index.add_cross_file_link(CrossFileLinkIndex {
2357 target_path: "/pkg/runtime.md".to_string(),
2358 fragment: "".to_string(),
2359 line: 5,
2360 column: 1,
2361 });
2362 file_index.add_cross_file_link(CrossFileLinkIndex {
2363 target_path: "/api/v1/users.md".to_string(),
2364 fragment: "section".to_string(),
2365 line: 10,
2366 column: 1,
2367 });
2368
2369 let warnings = rule
2371 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2372 .unwrap();
2373
2374 assert!(
2376 warnings.is_empty(),
2377 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2378 );
2379 }
2380
2381 #[test]
2382 fn test_protocol_relative_url_not_skipped() {
2383 let temp_dir = tempdir().unwrap();
2386 let base_path = temp_dir.path();
2387
2388 let content = r#"
2389# Test Document
2390
2391[External](//example.com/page)
2392[Another](//cdn.example.com/asset.js)
2393"#;
2394
2395 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2397 let result = rule.check(&ctx).unwrap();
2398
2399 assert!(
2401 result.is_empty(),
2402 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2403 );
2404 }
2405
2406 #[test]
2407 fn test_email_addresses_skipped() {
2408 let temp_dir = tempdir().unwrap();
2411 let base_path = temp_dir.path();
2412
2413 let content = r#"
2414# Test Document
2415
2416[Contact](user@example.com)
2417[Steering](steering@kubernetes.io)
2418[Support](john.doe+filter@company.co.uk)
2419[User](user_name@sub.domain.com)
2420"#;
2421
2422 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2423 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424 let result = rule.check(&ctx).unwrap();
2425
2426 assert!(
2428 result.is_empty(),
2429 "Email addresses should be skipped. Got warnings: {result:?}"
2430 );
2431 }
2432
2433 #[test]
2434 fn test_email_addresses_vs_file_paths() {
2435 let temp_dir = tempdir().unwrap();
2438 let base_path = temp_dir.path();
2439
2440 let content = r#"
2441# Test Document
2442
2443[Email](user@example.com) <!-- Should be skipped (email) -->
2444[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2445[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2446"#;
2447
2448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2449 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2450 let result = rule.check(&ctx).unwrap();
2451
2452 assert!(
2454 result.is_empty(),
2455 "All email addresses should be skipped. Got: {result:?}"
2456 );
2457 }
2458
2459 #[test]
2460 fn test_diagnostic_position_accuracy() {
2461 let temp_dir = tempdir().unwrap();
2463 let base_path = temp_dir.path();
2464
2465 let content = "prefix [text](missing.md) suffix";
2468 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2472 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2473 let result = rule.check(&ctx).unwrap();
2474
2475 assert_eq!(result.len(), 1, "Should have exactly one warning");
2476 assert_eq!(result[0].line, 1, "Should be on line 1");
2477 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2478 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2479 }
2480
2481 #[test]
2482 fn test_diagnostic_position_angle_brackets() {
2483 let temp_dir = tempdir().unwrap();
2485 let base_path = temp_dir.path();
2486
2487 let content = "[link](<missing.md>)";
2490 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2493 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2494 let result = rule.check(&ctx).unwrap();
2495
2496 assert_eq!(result.len(), 1, "Should have exactly one warning");
2497 assert_eq!(result[0].line, 1, "Should be on line 1");
2498 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2499 }
2500
2501 #[test]
2502 fn test_diagnostic_position_multiline() {
2503 let temp_dir = tempdir().unwrap();
2505 let base_path = temp_dir.path();
2506
2507 let content = r#"# Title
2508Some text on line 2
2509[link on line 3](missing1.md)
2510More text
2511[link on line 5](missing2.md)"#;
2512
2513 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2514 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2515 let result = rule.check(&ctx).unwrap();
2516
2517 assert_eq!(result.len(), 2, "Should have two warnings");
2518
2519 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2521 assert!(result[0].message.contains("missing1.md"));
2522
2523 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2525 assert!(result[1].message.contains("missing2.md"));
2526 }
2527
2528 #[test]
2529 fn test_diagnostic_position_with_spaces() {
2530 let temp_dir = tempdir().unwrap();
2532 let base_path = temp_dir.path();
2533
2534 let content = "[link]( missing.md )";
2535 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2540 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2541 let result = rule.check(&ctx).unwrap();
2542
2543 assert_eq!(result.len(), 1, "Should have exactly one warning");
2544 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2546 }
2547
2548 #[test]
2549 fn test_diagnostic_position_image() {
2550 let temp_dir = tempdir().unwrap();
2552 let base_path = temp_dir.path();
2553
2554 let content = "";
2555
2556 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2557 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2558 let result = rule.check(&ctx).unwrap();
2559
2560 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2561 assert_eq!(result[0].line, 1);
2562 assert!(result[0].column > 0, "Should have valid column position");
2564 assert!(result[0].message.contains("missing.jpg"));
2565 }
2566
2567 #[test]
2568 fn test_wikilinks_skipped() {
2569 let temp_dir = tempdir().unwrap();
2572 let base_path = temp_dir.path();
2573
2574 let content = r#"# Test Document
2575
2576[[Microsoft#Windows OS]]
2577[[SomePage]]
2578[[Page With Spaces]]
2579[[path/to/page#section]]
2580[[page|Display Text]]
2581
2582This is a [real missing link](missing.md) that should be flagged.
2583"#;
2584
2585 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2586 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2587 let result = rule.check(&ctx).unwrap();
2588
2589 assert_eq!(
2591 result.len(),
2592 1,
2593 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2594 );
2595 assert!(
2596 result[0].message.contains("missing.md"),
2597 "Warning should be for missing.md, not wikilinks"
2598 );
2599 }
2600
2601 #[test]
2602 fn test_wikilinks_not_added_to_index() {
2603 let temp_dir = tempdir().unwrap();
2605 let base_path = temp_dir.path();
2606
2607 let content = r#"# Test Document
2608
2609[[Microsoft#Windows OS]]
2610[[SomePage#section]]
2611[Regular Link](other.md)
2612"#;
2613
2614 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2616
2617 let mut file_index = FileIndex::new();
2618 rule.contribute_to_index(&ctx, &mut file_index);
2619
2620 let cross_file_links = &file_index.cross_file_links;
2623 assert_eq!(
2624 cross_file_links.len(),
2625 1,
2626 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2627 );
2628 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2629 }
2630
2631 #[test]
2632 fn test_reference_definition_missing_file() {
2633 let temp_dir = tempdir().unwrap();
2635 let base_path = temp_dir.path();
2636
2637 let content = r#"# Test Document
2638
2639[test]: ./missing.md
2640[example]: ./nonexistent.html
2641
2642Use [test] and [example] here.
2643"#;
2644
2645 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2646 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2647 let result = rule.check(&ctx).unwrap();
2648
2649 assert_eq!(
2651 result.len(),
2652 2,
2653 "Should have warnings for missing reference definition targets. Got: {result:?}"
2654 );
2655 assert!(
2656 result.iter().any(|w| w.message.contains("missing.md")),
2657 "Should warn about missing.md"
2658 );
2659 assert!(
2660 result.iter().any(|w| w.message.contains("nonexistent.html")),
2661 "Should warn about nonexistent.html"
2662 );
2663 }
2664
2665 #[test]
2666 fn test_reference_definition_existing_file() {
2667 let temp_dir = tempdir().unwrap();
2669 let base_path = temp_dir.path();
2670
2671 let exists_path = base_path.join("exists.md");
2673 File::create(&exists_path)
2674 .unwrap()
2675 .write_all(b"# Existing file")
2676 .unwrap();
2677
2678 let content = r#"# Test Document
2679
2680[test]: ./exists.md
2681
2682Use [test] here.
2683"#;
2684
2685 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2687 let result = rule.check(&ctx).unwrap();
2688
2689 assert!(
2691 result.is_empty(),
2692 "Should not warn about existing file. Got: {result:?}"
2693 );
2694 }
2695
2696 #[test]
2697 fn test_reference_definition_external_url_skipped() {
2698 let temp_dir = tempdir().unwrap();
2700 let base_path = temp_dir.path();
2701
2702 let content = r#"# Test Document
2703
2704[google]: https://google.com
2705[example]: http://example.org
2706[mail]: mailto:test@example.com
2707[ftp]: ftp://files.example.com
2708[local]: ./missing.md
2709
2710Use [google], [example], [mail], [ftp], [local] here.
2711"#;
2712
2713 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2714 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2715 let result = rule.check(&ctx).unwrap();
2716
2717 assert_eq!(
2719 result.len(),
2720 1,
2721 "Should only warn about local missing file. Got: {result:?}"
2722 );
2723 assert!(
2724 result[0].message.contains("missing.md"),
2725 "Warning should be for missing.md"
2726 );
2727 }
2728
2729 #[test]
2730 fn test_reference_definition_fragment_only_skipped() {
2731 let temp_dir = tempdir().unwrap();
2733 let base_path = temp_dir.path();
2734
2735 let content = r#"# Test Document
2736
2737[section]: #my-section
2738
2739Use [section] here.
2740"#;
2741
2742 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2743 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2744 let result = rule.check(&ctx).unwrap();
2745
2746 assert!(
2748 result.is_empty(),
2749 "Should not warn about fragment-only reference. Got: {result:?}"
2750 );
2751 }
2752
2753 #[test]
2754 fn test_reference_definition_column_position() {
2755 let temp_dir = tempdir().unwrap();
2757 let base_path = temp_dir.path();
2758
2759 let content = "[ref]: ./missing.md";
2762 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2766 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768
2769 assert_eq!(result.len(), 1, "Should have exactly one warning");
2770 assert_eq!(result[0].line, 1, "Should be on line 1");
2771 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2772 }
2773
2774 #[test]
2775 fn test_reference_definition_html_with_md_source() {
2776 let temp_dir = tempdir().unwrap();
2778 let base_path = temp_dir.path();
2779
2780 let md_file = base_path.join("guide.md");
2782 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2783
2784 let content = r#"# Test Document
2785
2786[guide]: ./guide.html
2787[missing]: ./missing.html
2788
2789Use [guide] and [missing] here.
2790"#;
2791
2792 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2793 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2794 let result = rule.check(&ctx).unwrap();
2795
2796 assert_eq!(
2798 result.len(),
2799 1,
2800 "Should only warn about missing source. Got: {result:?}"
2801 );
2802 assert!(result[0].message.contains("missing.html"));
2803 }
2804
2805 #[test]
2806 fn test_reference_definition_url_encoded() {
2807 let temp_dir = tempdir().unwrap();
2809 let base_path = temp_dir.path();
2810
2811 let file_with_spaces = base_path.join("file with spaces.md");
2813 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2814
2815 let content = r#"# Test Document
2816
2817[spaces]: ./file%20with%20spaces.md
2818[missing]: ./missing%20file.md
2819
2820Use [spaces] and [missing] here.
2821"#;
2822
2823 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2824 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2825 let result = rule.check(&ctx).unwrap();
2826
2827 assert_eq!(
2829 result.len(),
2830 1,
2831 "Should only warn about missing URL-encoded file. Got: {result:?}"
2832 );
2833 assert!(result[0].message.contains("missing%20file.md"));
2834 }
2835
2836 #[test]
2837 fn test_inline_and_reference_both_checked() {
2838 let temp_dir = tempdir().unwrap();
2840 let base_path = temp_dir.path();
2841
2842 let content = r#"# Test Document
2843
2844[inline link](./inline-missing.md)
2845[ref]: ./ref-missing.md
2846
2847Use [ref] here.
2848"#;
2849
2850 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2851 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2852 let result = rule.check(&ctx).unwrap();
2853
2854 assert_eq!(
2856 result.len(),
2857 2,
2858 "Should warn about both inline and reference links. Got: {result:?}"
2859 );
2860 assert!(
2861 result.iter().any(|w| w.message.contains("inline-missing.md")),
2862 "Should warn about inline-missing.md"
2863 );
2864 assert!(
2865 result.iter().any(|w| w.message.contains("ref-missing.md")),
2866 "Should warn about ref-missing.md"
2867 );
2868 }
2869
2870 #[test]
2871 fn test_footnote_definitions_not_flagged() {
2872 let rule = MD057ExistingRelativeLinks::default();
2875
2876 let content = r#"# Title
2877
2878A footnote[^1].
2879
2880[^1]: [link](https://www.google.com).
2881"#;
2882
2883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let result = rule.check(&ctx).unwrap();
2885
2886 assert!(
2887 result.is_empty(),
2888 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2889 );
2890 }
2891
2892 #[test]
2893 fn test_footnote_with_relative_link_inside() {
2894 let rule = MD057ExistingRelativeLinks::default();
2897
2898 let content = r#"# Title
2899
2900See the footnote[^1].
2901
2902[^1]: Check out [this file](./existing.md) for more info.
2903[^2]: Also see [missing](./does-not-exist.md).
2904"#;
2905
2906 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2907 let result = rule.check(&ctx).unwrap();
2908
2909 for warning in &result {
2914 assert!(
2915 !warning.message.contains("[this file]"),
2916 "Footnote content should not be treated as URL: {warning:?}"
2917 );
2918 assert!(
2919 !warning.message.contains("[missing]"),
2920 "Footnote content should not be treated as URL: {warning:?}"
2921 );
2922 }
2923 }
2924
2925 #[test]
2926 fn test_mixed_footnotes_and_reference_definitions() {
2927 let temp_dir = tempdir().unwrap();
2929 let base_path = temp_dir.path();
2930
2931 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2932
2933 let content = r#"# Title
2934
2935A footnote[^1] and a [ref link][myref].
2936
2937[^1]: This is a footnote with [link](https://example.com).
2938
2939[myref]: ./missing-file.md "This should be checked"
2940"#;
2941
2942 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2943 let result = rule.check(&ctx).unwrap();
2944
2945 assert_eq!(
2947 result.len(),
2948 1,
2949 "Should only warn about the regular reference definition. Got: {result:?}"
2950 );
2951 assert!(
2952 result[0].message.contains("missing-file.md"),
2953 "Should warn about missing-file.md in reference definition"
2954 );
2955 }
2956
2957 #[test]
2958 fn test_absolute_links_ignore_by_default() {
2959 let temp_dir = tempdir().unwrap();
2961 let base_path = temp_dir.path();
2962
2963 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2964
2965 let content = r#"# Links
2966
2967[API docs](/api/v1/users)
2968[Blog post](/blog/2024/release.html)
2969
2970
2971[ref]: /docs/reference.md
2972"#;
2973
2974 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2975 let result = rule.check(&ctx).unwrap();
2976
2977 assert!(
2979 result.is_empty(),
2980 "Absolute links should be ignored by default. Got: {result:?}"
2981 );
2982 }
2983
2984 #[test]
2985 fn test_absolute_links_warn_config() {
2986 let temp_dir = tempdir().unwrap();
2988 let base_path = temp_dir.path();
2989
2990 let config = MD057Config {
2991 absolute_links: AbsoluteLinksOption::Warn,
2992 ..Default::default()
2993 };
2994 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2995
2996 let content = r#"# Links
2997
2998[API docs](/api/v1/users)
2999[Blog post](/blog/2024/release.html)
3000"#;
3001
3002 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3003 let result = rule.check(&ctx).unwrap();
3004
3005 assert_eq!(
3007 result.len(),
3008 2,
3009 "Should warn about both absolute links. Got: {result:?}"
3010 );
3011 assert!(
3012 result[0].message.contains("cannot be validated locally"),
3013 "Warning should explain why: {}",
3014 result[0].message
3015 );
3016 assert!(
3017 result[0].message.contains("/api/v1/users"),
3018 "Warning should include the link path"
3019 );
3020 }
3021
3022 #[test]
3023 fn test_absolute_links_warn_images() {
3024 let temp_dir = tempdir().unwrap();
3026 let base_path = temp_dir.path();
3027
3028 let config = MD057Config {
3029 absolute_links: AbsoluteLinksOption::Warn,
3030 ..Default::default()
3031 };
3032 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3033
3034 let content = r#"# Images
3035
3036
3037"#;
3038
3039 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3040 let result = rule.check(&ctx).unwrap();
3041
3042 assert_eq!(
3043 result.len(),
3044 1,
3045 "Should warn about absolute image path. Got: {result:?}"
3046 );
3047 assert!(
3048 result[0].message.contains("/assets/logo.png"),
3049 "Warning should include the image path"
3050 );
3051 }
3052
3053 #[test]
3054 fn test_absolute_links_warn_reference_definitions() {
3055 let temp_dir = tempdir().unwrap();
3057 let base_path = temp_dir.path();
3058
3059 let config = MD057Config {
3060 absolute_links: AbsoluteLinksOption::Warn,
3061 ..Default::default()
3062 };
3063 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3064
3065 let content = r#"# Reference
3066
3067See the [docs][ref].
3068
3069[ref]: /docs/reference.md
3070"#;
3071
3072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3073 let result = rule.check(&ctx).unwrap();
3074
3075 assert_eq!(
3076 result.len(),
3077 1,
3078 "Should warn about absolute reference definition. Got: {result:?}"
3079 );
3080 assert!(
3081 result[0].message.contains("/docs/reference.md"),
3082 "Warning should include the reference path"
3083 );
3084 }
3085
3086 #[test]
3087 fn test_search_paths_inline_link() {
3088 let temp_dir = tempdir().unwrap();
3089 let base_path = temp_dir.path();
3090
3091 let assets_dir = base_path.join("assets");
3093 std::fs::create_dir_all(&assets_dir).unwrap();
3094 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3095
3096 let config = MD057Config {
3097 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3098 ..Default::default()
3099 };
3100 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3101
3102 let content = "# Test\n\n[Photo](photo.png)\n";
3103 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3104 let result = rule.check(&ctx).unwrap();
3105
3106 assert!(
3107 result.is_empty(),
3108 "Should find photo.png via search-paths. Got: {result:?}"
3109 );
3110 }
3111
3112 #[test]
3113 fn test_search_paths_image() {
3114 let temp_dir = tempdir().unwrap();
3115 let base_path = temp_dir.path();
3116
3117 let assets_dir = base_path.join("attachments");
3118 std::fs::create_dir_all(&assets_dir).unwrap();
3119 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3120
3121 let config = MD057Config {
3122 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3123 ..Default::default()
3124 };
3125 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3126
3127 let content = "# Test\n\n\n";
3128 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3129 let result = rule.check(&ctx).unwrap();
3130
3131 assert!(
3132 result.is_empty(),
3133 "Should find diagram.svg via search-paths. Got: {result:?}"
3134 );
3135 }
3136
3137 #[test]
3138 fn test_search_paths_reference_definition() {
3139 let temp_dir = tempdir().unwrap();
3140 let base_path = temp_dir.path();
3141
3142 let assets_dir = base_path.join("images");
3143 std::fs::create_dir_all(&assets_dir).unwrap();
3144 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3145
3146 let config = MD057Config {
3147 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3148 ..Default::default()
3149 };
3150 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3151
3152 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3153 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3154 let result = rule.check(&ctx).unwrap();
3155
3156 assert!(
3157 result.is_empty(),
3158 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3159 );
3160 }
3161
3162 #[test]
3163 fn test_search_paths_still_warns_when_truly_missing() {
3164 let temp_dir = tempdir().unwrap();
3165 let base_path = temp_dir.path();
3166
3167 let assets_dir = base_path.join("assets");
3168 std::fs::create_dir_all(&assets_dir).unwrap();
3169
3170 let config = MD057Config {
3171 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3172 ..Default::default()
3173 };
3174 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3175
3176 let content = "# Test\n\n\n";
3177 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3178 let result = rule.check(&ctx).unwrap();
3179
3180 assert_eq!(
3181 result.len(),
3182 1,
3183 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3184 );
3185 }
3186
3187 #[test]
3188 fn test_search_paths_nonexistent_directory() {
3189 let temp_dir = tempdir().unwrap();
3190 let base_path = temp_dir.path();
3191
3192 let config = MD057Config {
3193 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3194 ..Default::default()
3195 };
3196 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3197
3198 let content = "# Test\n\n\n";
3199 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200 let result = rule.check(&ctx).unwrap();
3201
3202 assert_eq!(
3203 result.len(),
3204 1,
3205 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3206 );
3207 }
3208
3209 #[test]
3210 fn test_obsidian_attachment_folder_named() {
3211 let temp_dir = tempdir().unwrap();
3212 let vault = temp_dir.path().join("vault");
3213 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3214 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3215 std::fs::create_dir_all(vault.join("notes")).unwrap();
3216
3217 std::fs::write(
3218 vault.join(".obsidian/app.json"),
3219 r#"{"attachmentFolderPath": "Attachments"}"#,
3220 )
3221 .unwrap();
3222 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3223
3224 let notes_dir = vault.join("notes");
3225 let source_file = notes_dir.join("test.md");
3226 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3227
3228 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3229
3230 let content = "# Test\n\n\n";
3231 let ctx =
3232 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3233 let result = rule.check(&ctx).unwrap();
3234
3235 assert!(
3236 result.is_empty(),
3237 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3238 );
3239 }
3240
3241 #[test]
3242 fn test_obsidian_attachment_same_folder_as_file() {
3243 let temp_dir = tempdir().unwrap();
3244 let vault = temp_dir.path().join("vault-rf");
3245 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3246 std::fs::create_dir_all(vault.join("notes")).unwrap();
3247
3248 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3249
3250 let notes_dir = vault.join("notes");
3252 let source_file = notes_dir.join("test.md");
3253 std::fs::write(&source_file, "placeholder").unwrap();
3254 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3255
3256 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3257
3258 let content = "# Test\n\n\n";
3259 let ctx =
3260 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3261 let result = rule.check(&ctx).unwrap();
3262
3263 assert!(
3264 result.is_empty(),
3265 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3271 let temp_dir = tempdir().unwrap();
3272 let vault = temp_dir.path().join("vault-nf");
3273 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3274 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3275 std::fs::create_dir_all(vault.join("notes")).unwrap();
3276
3277 std::fs::write(
3278 vault.join(".obsidian/app.json"),
3279 r#"{"attachmentFolderPath": "Attachments"}"#,
3280 )
3281 .unwrap();
3282 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3283
3284 let notes_dir = vault.join("notes");
3285 let source_file = notes_dir.join("test.md");
3286 std::fs::write(&source_file, "placeholder").unwrap();
3287
3288 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3289
3290 let content = "# Test\n\n\n";
3291 let ctx =
3293 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3294 let result = rule.check(&ctx).unwrap();
3295
3296 assert_eq!(
3297 result.len(),
3298 1,
3299 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3300 );
3301 }
3302
3303 #[test]
3304 fn test_search_paths_combined_with_obsidian() {
3305 let temp_dir = tempdir().unwrap();
3306 let vault = temp_dir.path().join("vault-combo");
3307 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3308 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3309 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3310 std::fs::create_dir_all(vault.join("notes")).unwrap();
3311
3312 std::fs::write(
3313 vault.join(".obsidian/app.json"),
3314 r#"{"attachmentFolderPath": "Attachments"}"#,
3315 )
3316 .unwrap();
3317 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3318 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3319
3320 let notes_dir = vault.join("notes");
3321 let source_file = notes_dir.join("test.md");
3322 std::fs::write(&source_file, "placeholder").unwrap();
3323
3324 let extra_assets_dir = vault.join("extra-assets");
3325 let config = MD057Config {
3326 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3327 ..Default::default()
3328 };
3329 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3330
3331 let content = "# Test\n\n\n\n\n";
3333 let ctx =
3334 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3335 let result = rule.check(&ctx).unwrap();
3336
3337 assert!(
3338 result.is_empty(),
3339 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3340 );
3341 }
3342
3343 #[test]
3344 fn test_obsidian_attachment_subfolder_under_file() {
3345 let temp_dir = tempdir().unwrap();
3346 let vault = temp_dir.path().join("vault-sub");
3347 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3348 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3349
3350 std::fs::write(
3351 vault.join(".obsidian/app.json"),
3352 r#"{"attachmentFolderPath": "./assets"}"#,
3353 )
3354 .unwrap();
3355 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3356
3357 let notes_dir = vault.join("notes");
3358 let source_file = notes_dir.join("test.md");
3359 std::fs::write(&source_file, "placeholder").unwrap();
3360
3361 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3362
3363 let content = "# Test\n\n\n";
3364 let ctx =
3365 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3366 let result = rule.check(&ctx).unwrap();
3367
3368 assert!(
3369 result.is_empty(),
3370 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3371 );
3372 }
3373
3374 #[test]
3375 fn test_obsidian_attachment_vault_root() {
3376 let temp_dir = tempdir().unwrap();
3377 let vault = temp_dir.path().join("vault-root");
3378 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3379 std::fs::create_dir_all(vault.join("notes")).unwrap();
3380
3381 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3383 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3384
3385 let notes_dir = vault.join("notes");
3386 let source_file = notes_dir.join("test.md");
3387 std::fs::write(&source_file, "placeholder").unwrap();
3388
3389 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3390
3391 let content = "# Test\n\n\n";
3392 let ctx =
3393 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3394 let result = rule.check(&ctx).unwrap();
3395
3396 assert!(
3397 result.is_empty(),
3398 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3399 );
3400 }
3401
3402 #[test]
3403 fn test_search_paths_multiple_directories() {
3404 let temp_dir = tempdir().unwrap();
3405 let base_path = temp_dir.path();
3406
3407 let dir_a = base_path.join("dir-a");
3408 let dir_b = base_path.join("dir-b");
3409 std::fs::create_dir_all(&dir_a).unwrap();
3410 std::fs::create_dir_all(&dir_b).unwrap();
3411 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3412 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3413
3414 let config = MD057Config {
3415 search_paths: vec![
3416 dir_a.to_string_lossy().into_owned(),
3417 dir_b.to_string_lossy().into_owned(),
3418 ],
3419 ..Default::default()
3420 };
3421 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3422
3423 let content = "# Test\n\n\n\n\n";
3424 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3425 let result = rule.check(&ctx).unwrap();
3426
3427 assert!(
3428 result.is_empty(),
3429 "Should find files across multiple search paths. Got: {result:?}"
3430 );
3431 }
3432
3433 #[test]
3434 fn test_cross_file_check_with_search_paths() {
3435 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3436
3437 let temp_dir = tempdir().unwrap();
3438 let base_path = temp_dir.path();
3439
3440 let docs_dir = base_path.join("docs");
3442 std::fs::create_dir_all(&docs_dir).unwrap();
3443 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3444
3445 let config = MD057Config {
3446 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3447 ..Default::default()
3448 };
3449 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3450
3451 let file_path = base_path.join("README.md");
3452 std::fs::write(&file_path, "# Readme\n").unwrap();
3453
3454 let mut file_index = FileIndex::default();
3455 file_index.cross_file_links.push(CrossFileLinkIndex {
3456 target_path: "guide.md".to_string(),
3457 fragment: String::new(),
3458 line: 3,
3459 column: 1,
3460 });
3461
3462 let workspace_index = WorkspaceIndex::new();
3463
3464 let result = rule
3465 .cross_file_check(&file_path, &file_index, &workspace_index)
3466 .unwrap();
3467
3468 assert!(
3469 result.is_empty(),
3470 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3471 );
3472 }
3473
3474 #[test]
3475 fn test_cross_file_check_with_obsidian_flavor() {
3476 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3477
3478 let temp_dir = tempdir().unwrap();
3479 let vault = temp_dir.path().join("vault-xf");
3480 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3481 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3482 std::fs::create_dir_all(vault.join("notes")).unwrap();
3483
3484 std::fs::write(
3485 vault.join(".obsidian/app.json"),
3486 r#"{"attachmentFolderPath": "Attachments"}"#,
3487 )
3488 .unwrap();
3489 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3490
3491 let notes_dir = vault.join("notes");
3492 let file_path = notes_dir.join("test.md");
3493 std::fs::write(&file_path, "placeholder").unwrap();
3494
3495 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3496 .with_path(¬es_dir)
3497 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3498
3499 let mut file_index = FileIndex::default();
3500 file_index.cross_file_links.push(CrossFileLinkIndex {
3501 target_path: "ref.md".to_string(),
3502 fragment: String::new(),
3503 line: 3,
3504 column: 1,
3505 });
3506
3507 let workspace_index = WorkspaceIndex::new();
3508
3509 let result = rule
3510 .cross_file_check(&file_path, &file_index, &workspace_index)
3511 .unwrap();
3512
3513 assert!(
3514 result.is_empty(),
3515 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3516 );
3517 }
3518
3519 #[test]
3520 fn test_cross_file_check_clears_stale_cache() {
3521 use crate::workspace_index::WorkspaceIndex;
3524
3525 let rule = MD057ExistingRelativeLinks::new();
3526
3527 {
3530 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3531 cache.insert(PathBuf::from("docs/phantom.md"), true);
3532 }
3533
3534 let workspace_index = WorkspaceIndex::new();
3535
3536 let mut file_index = FileIndex::new();
3537 file_index.add_cross_file_link(CrossFileLinkIndex {
3538 target_path: "phantom.md".to_string(),
3539 fragment: "".to_string(),
3540 line: 1,
3541 column: 1,
3542 });
3543
3544 let warnings = rule
3545 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3546 .unwrap();
3547
3548 assert_eq!(
3550 warnings.len(),
3551 1,
3552 "cross_file_check should report missing file after clearing stale cache. Got: {warnings:?}"
3553 );
3554 assert!(warnings[0].message.contains("phantom.md"));
3555 }
3556
3557 #[test]
3558 fn test_cross_file_check_does_not_carry_over_cache_between_runs() {
3559 use crate::workspace_index::WorkspaceIndex;
3561
3562 let rule = MD057ExistingRelativeLinks::new();
3563 let workspace_index = WorkspaceIndex::new();
3564
3565 let mut file_index_1 = FileIndex::new();
3567 file_index_1.add_cross_file_link(CrossFileLinkIndex {
3568 target_path: "nonexistent.md".to_string(),
3569 fragment: "".to_string(),
3570 line: 1,
3571 column: 1,
3572 });
3573
3574 let warnings_1 = rule
3575 .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3576 .unwrap();
3577 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3578
3579 {
3581 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3582 cache.insert(PathBuf::from("docs/nonexistent.md"), true);
3583 }
3584
3585 let warnings_2 = rule
3587 .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3588 .unwrap();
3589
3590 assert_eq!(
3592 warnings_2.len(),
3593 1,
3594 "Second run should still detect missing file after cache reset. Got: {warnings_2:?}"
3595 );
3596 }
3597}