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(|p| p.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
434impl Rule for MD057ExistingRelativeLinks {
435 fn name(&self) -> &'static str {
436 "MD057"
437 }
438
439 fn description(&self) -> &'static str {
440 "Relative links should point to existing files"
441 }
442
443 fn category(&self) -> RuleCategory {
444 RuleCategory::Link
445 }
446
447 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
448 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
449 }
450
451 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
452 let content = ctx.content;
453
454 if content.is_empty() || !content.contains('[') {
456 return Ok(Vec::new());
457 }
458
459 if !content.contains("](") && !content.contains("]:") {
462 return Ok(Vec::new());
463 }
464
465 reset_file_existence_cache();
467
468 let mut warnings = Vec::new();
469
470 let base_path: Option<PathBuf> = {
474 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
476 if explicit_base.is_some() {
477 explicit_base
478 } else if let Some(ref source_file) = ctx.source_file {
479 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
483 resolved_file
484 .parent()
485 .map(|p| p.to_path_buf())
486 .or_else(|| Some(CURRENT_DIR.clone()))
487 } else {
488 None
490 }
491 };
492
493 let Some(base_path) = base_path else {
495 return Ok(warnings);
496 };
497
498 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path);
500
501 if !ctx.links.is_empty() {
503 let line_index = &ctx.line_index;
505
506 let lines = ctx.raw_lines();
508
509 let mut processed_lines = std::collections::HashSet::new();
512
513 for link in &ctx.links {
514 let line_idx = link.line - 1;
515 if line_idx >= lines.len() {
516 continue;
517 }
518
519 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
521 continue;
522 }
523
524 if !processed_lines.insert(line_idx) {
526 continue;
527 }
528
529 let line = lines[line_idx];
530
531 if !line.contains("](") {
533 continue;
534 }
535
536 for link_match in LINK_START_REGEX.find_iter(line) {
538 let start_pos = link_match.start();
539 let end_pos = link_match.end();
540
541 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
543 let absolute_start_pos = line_start_byte + start_pos;
544
545 if ctx.is_in_code_span_byte(absolute_start_pos) {
547 continue;
548 }
549
550 if ctx.is_in_math_span(absolute_start_pos) {
552 continue;
553 }
554
555 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
559 .captures_at(line, end_pos - 1)
560 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
561 .or_else(|| {
562 URL_EXTRACT_REGEX
563 .captures_at(line, end_pos - 1)
564 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
565 });
566
567 if let Some((caps, url_group)) = caps_and_url {
568 let url = url_group.as_str().trim();
569
570 if url.is_empty() {
572 continue;
573 }
574
575 if url.starts_with('`') && url.ends_with('`') {
579 continue;
580 }
581
582 if self.is_external_url(url) || self.is_fragment_only_link(url) {
584 continue;
585 }
586
587 if Self::is_absolute_path(url) {
589 match self.config.absolute_links {
590 AbsoluteLinksOption::Warn => {
591 let url_start = url_group.start();
592 let url_end = url_group.end();
593 warnings.push(LintWarning {
594 rule_name: Some(self.name().to_string()),
595 line: link.line,
596 column: url_start + 1,
597 end_line: link.line,
598 end_column: url_end + 1,
599 message: format!("Absolute link '{url}' cannot be validated locally"),
600 severity: Severity::Warning,
601 fix: None,
602 });
603 }
604 AbsoluteLinksOption::RelativeToDocs => {
605 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
606 let url_start = url_group.start();
607 let url_end = url_group.end();
608 warnings.push(LintWarning {
609 rule_name: Some(self.name().to_string()),
610 line: link.line,
611 column: url_start + 1,
612 end_line: link.line,
613 end_column: url_end + 1,
614 message: msg,
615 severity: Severity::Warning,
616 fix: None,
617 });
618 }
619 }
620 AbsoluteLinksOption::Ignore => {}
621 }
622 continue;
623 }
624
625 let full_url_for_compact = if let Some(frag) = caps.get(2) {
629 format!("{url}{}", frag.as_str())
630 } else {
631 url.to_string()
632 };
633 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
634 let url_start = url_group.start();
635 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
636 let fix_byte_start = line_start_byte + url_start;
637 let fix_byte_end = line_start_byte + url_end;
638 warnings.push(LintWarning {
639 rule_name: Some(self.name().to_string()),
640 line: link.line,
641 column: url_start + 1,
642 end_line: link.line,
643 end_column: url_end + 1,
644 message: format!(
645 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
646 ),
647 severity: Severity::Warning,
648 fix: Some(Fix {
649 range: fix_byte_start..fix_byte_end,
650 replacement: suggestion,
651 }),
652 });
653 }
654
655 let file_path = Self::strip_query_and_fragment(url);
657
658 let decoded_path = Self::url_decode(file_path);
660
661 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
663
664 if file_exists_or_markdown_extension(&resolved_path) {
666 continue; }
668
669 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
671 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
672 && let (Some(stem), Some(parent)) = (
673 resolved_path.file_stem().and_then(|s| s.to_str()),
674 resolved_path.parent(),
675 ) {
676 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
677 let source_path = parent.join(format!("{stem}{md_ext}"));
678 file_exists_with_cache(&source_path)
679 })
680 } else {
681 false
682 };
683
684 if has_md_source {
685 continue; }
687
688 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
690 continue;
691 }
692
693 let url_start = url_group.start();
697 let url_end = url_group.end();
698
699 warnings.push(LintWarning {
700 rule_name: Some(self.name().to_string()),
701 line: link.line,
702 column: url_start + 1, end_line: link.line,
704 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
706 severity: Severity::Error,
707 fix: None,
708 });
709 }
710 }
711 }
712 }
713
714 for image in &ctx.images {
716 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
718 continue;
719 }
720
721 let url = image.url.as_ref();
722
723 if url.is_empty() {
725 continue;
726 }
727
728 if self.is_external_url(url) || self.is_fragment_only_link(url) {
730 continue;
731 }
732
733 if Self::is_absolute_path(url) {
735 match self.config.absolute_links {
736 AbsoluteLinksOption::Warn => {
737 warnings.push(LintWarning {
738 rule_name: Some(self.name().to_string()),
739 line: image.line,
740 column: image.start_col + 1,
741 end_line: image.line,
742 end_column: image.start_col + 1 + url.len(),
743 message: format!("Absolute link '{url}' cannot be validated locally"),
744 severity: Severity::Warning,
745 fix: None,
746 });
747 }
748 AbsoluteLinksOption::RelativeToDocs => {
749 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
750 warnings.push(LintWarning {
751 rule_name: Some(self.name().to_string()),
752 line: image.line,
753 column: image.start_col + 1,
754 end_line: image.line,
755 end_column: image.start_col + 1 + url.len(),
756 message: msg,
757 severity: Severity::Warning,
758 fix: None,
759 });
760 }
761 }
762 AbsoluteLinksOption::Ignore => {}
763 }
764 continue;
765 }
766
767 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
769 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
772 let fix_byte_start = image.byte_offset + url_offset;
773 let fix_byte_end = fix_byte_start + url.len();
774 Fix {
775 range: fix_byte_start..fix_byte_end,
776 replacement: suggestion.clone(),
777 }
778 });
779
780 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
781 let url_col = fix
782 .as_ref()
783 .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
784 warnings.push(LintWarning {
785 rule_name: Some(self.name().to_string()),
786 line: image.line,
787 column: url_col,
788 end_line: image.line,
789 end_column: url_col + url.len(),
790 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
791 severity: Severity::Warning,
792 fix,
793 });
794 }
795
796 let file_path = Self::strip_query_and_fragment(url);
798
799 let decoded_path = Self::url_decode(file_path);
801
802 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
804
805 if file_exists_or_markdown_extension(&resolved_path) {
807 continue; }
809
810 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
812 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
813 && let (Some(stem), Some(parent)) = (
814 resolved_path.file_stem().and_then(|s| s.to_str()),
815 resolved_path.parent(),
816 ) {
817 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
818 let source_path = parent.join(format!("{stem}{md_ext}"));
819 file_exists_with_cache(&source_path)
820 })
821 } else {
822 false
823 };
824
825 if has_md_source {
826 continue; }
828
829 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
831 continue;
832 }
833
834 warnings.push(LintWarning {
837 rule_name: Some(self.name().to_string()),
838 line: image.line,
839 column: image.start_col + 1,
840 end_line: image.line,
841 end_column: image.start_col + 1 + url.len(),
842 message: format!("Relative link '{url}' does not exist"),
843 severity: Severity::Error,
844 fix: None,
845 });
846 }
847
848 for ref_def in &ctx.reference_defs {
850 let url = &ref_def.url;
851
852 if url.is_empty() {
854 continue;
855 }
856
857 if self.is_external_url(url) || self.is_fragment_only_link(url) {
859 continue;
860 }
861
862 if Self::is_absolute_path(url) {
864 match self.config.absolute_links {
865 AbsoluteLinksOption::Warn => {
866 let line_idx = ref_def.line - 1;
867 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
868 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
869 });
870 warnings.push(LintWarning {
871 rule_name: Some(self.name().to_string()),
872 line: ref_def.line,
873 column,
874 end_line: ref_def.line,
875 end_column: column + url.len(),
876 message: format!("Absolute link '{url}' cannot be validated locally"),
877 severity: Severity::Warning,
878 fix: None,
879 });
880 }
881 AbsoluteLinksOption::RelativeToDocs => {
882 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
883 let line_idx = ref_def.line - 1;
884 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
885 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
886 });
887 warnings.push(LintWarning {
888 rule_name: Some(self.name().to_string()),
889 line: ref_def.line,
890 column,
891 end_line: ref_def.line,
892 end_column: column + url.len(),
893 message: msg,
894 severity: Severity::Warning,
895 fix: None,
896 });
897 }
898 }
899 AbsoluteLinksOption::Ignore => {}
900 }
901 continue;
902 }
903
904 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
906 let ref_line_idx = ref_def.line - 1;
907 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
908 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
909 });
910 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
911 let fix_byte_start = ref_line_start_byte + col - 1;
912 let fix_byte_end = fix_byte_start + url.len();
913 warnings.push(LintWarning {
914 rule_name: Some(self.name().to_string()),
915 line: ref_def.line,
916 column: col,
917 end_line: ref_def.line,
918 end_column: col + url.len(),
919 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
920 severity: Severity::Warning,
921 fix: Some(Fix {
922 range: fix_byte_start..fix_byte_end,
923 replacement: suggestion,
924 }),
925 });
926 }
927
928 let file_path = Self::strip_query_and_fragment(url);
930
931 let decoded_path = Self::url_decode(file_path);
933
934 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
936
937 if file_exists_or_markdown_extension(&resolved_path) {
939 continue; }
941
942 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
944 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
945 && let (Some(stem), Some(parent)) = (
946 resolved_path.file_stem().and_then(|s| s.to_str()),
947 resolved_path.parent(),
948 ) {
949 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
950 let source_path = parent.join(format!("{stem}{md_ext}"));
951 file_exists_with_cache(&source_path)
952 })
953 } else {
954 false
955 };
956
957 if has_md_source {
958 continue; }
960
961 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
963 continue;
964 }
965
966 let line_idx = ref_def.line - 1;
969 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
970 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
972 });
973
974 warnings.push(LintWarning {
975 rule_name: Some(self.name().to_string()),
976 line: ref_def.line,
977 column,
978 end_line: ref_def.line,
979 end_column: column + url.len(),
980 message: format!("Relative link '{url}' does not exist"),
981 severity: Severity::Error,
982 fix: None,
983 });
984 }
985
986 Ok(warnings)
987 }
988
989 fn fix_capability(&self) -> FixCapability {
990 if self.config.compact_paths {
991 FixCapability::ConditionallyFixable
992 } else {
993 FixCapability::Unfixable
994 }
995 }
996
997 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
998 if !self.config.compact_paths {
999 return Ok(ctx.content.to_string());
1000 }
1001
1002 let warnings = self.check(ctx)?;
1003 let warnings =
1004 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1005 let mut content = ctx.content.to_string();
1006
1007 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1009 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
1010
1011 for fix in fixes {
1012 if fix.range.end <= content.len() {
1013 content.replace_range(fix.range.clone(), &fix.replacement);
1014 }
1015 }
1016
1017 Ok(content)
1018 }
1019
1020 fn as_any(&self) -> &dyn std::any::Any {
1021 self
1022 }
1023
1024 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1025 let default_config = MD057Config::default();
1026 let json_value = serde_json::to_value(&default_config).ok()?;
1027 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1028
1029 if let toml::Value::Table(table) = toml_value {
1030 if !table.is_empty() {
1031 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1032 } else {
1033 None
1034 }
1035 } else {
1036 None
1037 }
1038 }
1039
1040 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1041 where
1042 Self: Sized,
1043 {
1044 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1045 let mut rule = Self::from_config_struct(rule_config);
1046 rule.flavor = config.global.flavor;
1047 Box::new(rule)
1048 }
1049
1050 fn cross_file_scope(&self) -> CrossFileScope {
1051 CrossFileScope::Workspace
1052 }
1053
1054 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1055 for link in extract_cross_file_links(ctx) {
1058 index.add_cross_file_link(link);
1059 }
1060 }
1061
1062 fn cross_file_check(
1063 &self,
1064 file_path: &Path,
1065 file_index: &FileIndex,
1066 workspace_index: &crate::workspace_index::WorkspaceIndex,
1067 ) -> LintResult {
1068 reset_file_existence_cache();
1070
1071 let mut warnings = Vec::new();
1072
1073 let file_dir = file_path.parent();
1075
1076 let base_path = file_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| CURRENT_DIR.clone());
1078 let extra_search_paths = self.compute_search_paths(self.flavor, Some(file_path), &base_path);
1079
1080 for cross_link in &file_index.cross_file_links {
1081 let decoded_target = Self::url_decode(&cross_link.target_path);
1084
1085 if decoded_target.starts_with('/') {
1089 continue;
1090 }
1091
1092 let target_path = if let Some(dir) = file_dir {
1094 dir.join(&decoded_target)
1095 } else {
1096 Path::new(&decoded_target).to_path_buf()
1097 };
1098
1099 let target_path = normalize_path(&target_path);
1101
1102 let file_exists =
1104 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1105
1106 if !file_exists {
1107 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1110 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1111 && let (Some(stem), Some(parent)) =
1112 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1113 {
1114 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1115 let source_path = parent.join(format!("{stem}{md_ext}"));
1116 workspace_index.contains_file(&source_path) || source_path.exists()
1117 })
1118 } else {
1119 false
1120 };
1121
1122 if !has_md_source && !Self::exists_in_search_paths(&decoded_target, &extra_search_paths) {
1123 warnings.push(LintWarning {
1124 rule_name: Some(self.name().to_string()),
1125 line: cross_link.line,
1126 column: cross_link.column,
1127 end_line: cross_link.line,
1128 end_column: cross_link.column + cross_link.target_path.len(),
1129 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1130 severity: Severity::Error,
1131 fix: None,
1132 });
1133 }
1134 }
1135 }
1136
1137 Ok(warnings)
1138 }
1139}
1140
1141fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1146 let from_components: Vec<_> = from_dir.components().collect();
1147 let to_components: Vec<_> = to_path.components().collect();
1148
1149 let common_len = from_components
1151 .iter()
1152 .zip(to_components.iter())
1153 .take_while(|(a, b)| a == b)
1154 .count();
1155
1156 let mut result = PathBuf::new();
1157
1158 for _ in common_len..from_components.len() {
1160 result.push("..");
1161 }
1162
1163 for component in &to_components[common_len..] {
1165 result.push(component);
1166 }
1167
1168 result
1169}
1170
1171fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1177 let link_path = Path::new(raw_link_path);
1178
1179 let has_traversal = link_path
1181 .components()
1182 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1183
1184 if !has_traversal {
1185 return None;
1186 }
1187
1188 let combined = source_dir.join(link_path);
1190 let normalized_target = normalize_path(&combined);
1191
1192 let normalized_source = normalize_path(source_dir);
1194 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1195
1196 if shortest != link_path {
1198 let compact = shortest.to_string_lossy().to_string();
1199 if compact.is_empty() {
1201 return None;
1202 }
1203 Some(compact.replace('\\', "/"))
1205 } else {
1206 None
1207 }
1208}
1209
1210fn normalize_path(path: &Path) -> PathBuf {
1212 let mut components = Vec::new();
1213
1214 for component in path.components() {
1215 match component {
1216 std::path::Component::ParentDir => {
1217 if !components.is_empty() {
1219 components.pop();
1220 }
1221 }
1222 std::path::Component::CurDir => {
1223 }
1225 _ => {
1226 components.push(component);
1227 }
1228 }
1229 }
1230
1231 components.iter().collect()
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236 use super::*;
1237 use crate::workspace_index::CrossFileLinkIndex;
1238 use std::fs::File;
1239 use std::io::Write;
1240 use tempfile::tempdir;
1241
1242 #[test]
1243 fn test_strip_query_and_fragment() {
1244 assert_eq!(
1246 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1247 "file.png"
1248 );
1249 assert_eq!(
1250 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1251 "file.png"
1252 );
1253 assert_eq!(
1254 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1255 "file.png"
1256 );
1257
1258 assert_eq!(
1260 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1261 "file.md"
1262 );
1263 assert_eq!(
1264 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1265 "file.md"
1266 );
1267
1268 assert_eq!(
1270 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1271 "file.md"
1272 );
1273
1274 assert_eq!(
1276 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1277 "file.png"
1278 );
1279
1280 assert_eq!(
1282 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1283 "path/to/image.png"
1284 );
1285 assert_eq!(
1286 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1287 "path/to/image.png"
1288 );
1289
1290 assert_eq!(
1292 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1293 "file.md"
1294 );
1295 }
1296
1297 #[test]
1298 fn test_url_decode() {
1299 assert_eq!(
1301 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1302 "penguin with space.jpg"
1303 );
1304
1305 assert_eq!(
1307 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1308 "assets/my file name.png"
1309 );
1310
1311 assert_eq!(
1313 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1314 "hello world!.md"
1315 );
1316
1317 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1319
1320 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1322
1323 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1325
1326 assert_eq!(
1328 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1329 "normal-file.md"
1330 );
1331
1332 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1334
1335 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1337
1338 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1340
1341 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1343
1344 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1346
1347 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1349
1350 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1352
1353 assert_eq!(
1355 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1356 "path/to/file.md"
1357 );
1358
1359 assert_eq!(
1361 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1362 "hello world/foo bar.md"
1363 );
1364
1365 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1367
1368 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1370 }
1371
1372 #[test]
1373 fn test_url_encoded_filenames() {
1374 let temp_dir = tempdir().unwrap();
1376 let base_path = temp_dir.path();
1377
1378 let file_with_spaces = base_path.join("penguin with space.jpg");
1380 File::create(&file_with_spaces)
1381 .unwrap()
1382 .write_all(b"image data")
1383 .unwrap();
1384
1385 let subdir = base_path.join("my images");
1387 std::fs::create_dir(&subdir).unwrap();
1388 let nested_file = subdir.join("photo 1.png");
1389 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1390
1391 let content = r#"
1393# Test Document with URL-Encoded Links
1394
1395
1396
1397
1398"#;
1399
1400 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1401
1402 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1403 let result = rule.check(&ctx).unwrap();
1404
1405 assert_eq!(
1407 result.len(),
1408 1,
1409 "Should only warn about missing%20file.jpg. Got: {result:?}"
1410 );
1411 assert!(
1412 result[0].message.contains("missing%20file.jpg"),
1413 "Warning should mention the URL-encoded filename"
1414 );
1415 }
1416
1417 #[test]
1418 fn test_external_urls() {
1419 let rule = MD057ExistingRelativeLinks::new();
1420
1421 assert!(rule.is_external_url("https://example.com"));
1423 assert!(rule.is_external_url("http://example.com"));
1424 assert!(rule.is_external_url("ftp://example.com"));
1425 assert!(rule.is_external_url("www.example.com"));
1426 assert!(rule.is_external_url("example.com"));
1427
1428 assert!(rule.is_external_url("file:///path/to/file"));
1430 assert!(rule.is_external_url("smb://server/share"));
1431 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1432 assert!(rule.is_external_url("mailto:user@example.com"));
1433 assert!(rule.is_external_url("tel:+1234567890"));
1434 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1435 assert!(rule.is_external_url("javascript:void(0)"));
1436 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1437 assert!(rule.is_external_url("git://github.com/repo.git"));
1438
1439 assert!(rule.is_external_url("user@example.com"));
1442 assert!(rule.is_external_url("steering@kubernetes.io"));
1443 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1444 assert!(rule.is_external_url("user_name@sub.domain.com"));
1445 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1446
1447 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"));
1458 assert!(!rule.is_external_url("/blog/2024/release.html"));
1459 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1460 assert!(!rule.is_external_url("/pkg/runtime"));
1461 assert!(!rule.is_external_url("/doc/go1compat"));
1462 assert!(!rule.is_external_url("/index.html"));
1463 assert!(!rule.is_external_url("/assets/logo.png"));
1464
1465 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1467 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1468 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1469 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1470 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1471
1472 assert!(rule.is_external_url("~/assets/image.png"));
1475 assert!(rule.is_external_url("~/components/Button.vue"));
1476 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1480 assert!(rule.is_external_url("@images/photo.jpg"));
1481 assert!(rule.is_external_url("@assets/styles.css"));
1482
1483 assert!(!rule.is_external_url("./relative/path.md"));
1485 assert!(!rule.is_external_url("relative/path.md"));
1486 assert!(!rule.is_external_url("../parent/path.md"));
1487 }
1488
1489 #[test]
1490 fn test_framework_path_aliases() {
1491 let temp_dir = tempdir().unwrap();
1493 let base_path = temp_dir.path();
1494
1495 let content = r#"
1497# Framework Path Aliases
1498
1499
1500
1501
1502
1503[Link](@/pages/about.md)
1504
1505This is a [real missing link](missing.md) that should be flagged.
1506"#;
1507
1508 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1509
1510 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1511 let result = rule.check(&ctx).unwrap();
1512
1513 assert_eq!(
1515 result.len(),
1516 1,
1517 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1518 );
1519 assert!(
1520 result[0].message.contains("missing.md"),
1521 "Warning should be for missing.md"
1522 );
1523 }
1524
1525 #[test]
1526 fn test_url_decode_security_path_traversal() {
1527 let temp_dir = tempdir().unwrap();
1530 let base_path = temp_dir.path();
1531
1532 let file_in_base = base_path.join("safe.md");
1534 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1535
1536 let content = r#"
1541[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1542[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1543[Safe link](safe.md)
1544"#;
1545
1546 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1547
1548 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1549 let result = rule.check(&ctx).unwrap();
1550
1551 assert_eq!(
1554 result.len(),
1555 2,
1556 "Should have warnings for traversal attempts. Got: {result:?}"
1557 );
1558 }
1559
1560 #[test]
1561 fn test_url_encoded_utf8_filenames() {
1562 let temp_dir = tempdir().unwrap();
1564 let base_path = temp_dir.path();
1565
1566 let cafe_file = base_path.join("café.md");
1568 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1569
1570 let content = r#"
1571[Café link](caf%C3%A9.md)
1572[Missing unicode](r%C3%A9sum%C3%A9.md)
1573"#;
1574
1575 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1576
1577 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1578 let result = rule.check(&ctx).unwrap();
1579
1580 assert_eq!(
1582 result.len(),
1583 1,
1584 "Should only warn about missing résumé.md. Got: {result:?}"
1585 );
1586 assert!(
1587 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1588 "Warning should mention the URL-encoded filename"
1589 );
1590 }
1591
1592 #[test]
1593 fn test_url_encoded_emoji_filenames() {
1594 let temp_dir = tempdir().unwrap();
1597 let base_path = temp_dir.path();
1598
1599 let emoji_dir = base_path.join("👤 Personal");
1601 std::fs::create_dir(&emoji_dir).unwrap();
1602
1603 let file_path = emoji_dir.join("TV Shows.md");
1605 File::create(&file_path)
1606 .unwrap()
1607 .write_all(b"# TV Shows\n\nContent here.")
1608 .unwrap();
1609
1610 let content = r#"
1613# Test Document
1614
1615[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1616[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1617"#;
1618
1619 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1620
1621 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1622 let result = rule.check(&ctx).unwrap();
1623
1624 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1626 assert!(
1627 result[0].message.contains("Missing.md"),
1628 "Warning should be for Missing.md, got: {}",
1629 result[0].message
1630 );
1631 }
1632
1633 #[test]
1634 fn test_no_warnings_without_base_path() {
1635 let rule = MD057ExistingRelativeLinks::new();
1636 let content = "[Link](missing.md)";
1637
1638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1639 let result = rule.check(&ctx).unwrap();
1640 assert!(result.is_empty(), "Should have no warnings without base path");
1641 }
1642
1643 #[test]
1644 fn test_existing_and_missing_links() {
1645 let temp_dir = tempdir().unwrap();
1647 let base_path = temp_dir.path();
1648
1649 let exists_path = base_path.join("exists.md");
1651 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1652
1653 assert!(exists_path.exists(), "exists.md should exist for this test");
1655
1656 let content = r#"
1658# Test Document
1659
1660[Valid Link](exists.md)
1661[Invalid Link](missing.md)
1662[External Link](https://example.com)
1663[Media Link](image.jpg)
1664 "#;
1665
1666 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1668
1669 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1671 let result = rule.check(&ctx).unwrap();
1672
1673 assert_eq!(result.len(), 2);
1675 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1676 assert!(messages.iter().any(|m| m.contains("missing.md")));
1677 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1678 }
1679
1680 #[test]
1681 fn test_angle_bracket_links() {
1682 let temp_dir = tempdir().unwrap();
1684 let base_path = temp_dir.path();
1685
1686 let exists_path = base_path.join("exists.md");
1688 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1689
1690 let content = r#"
1692# Test Document
1693
1694[Valid Link](<exists.md>)
1695[Invalid Link](<missing.md>)
1696[External Link](<https://example.com>)
1697 "#;
1698
1699 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1701
1702 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1703 let result = rule.check(&ctx).unwrap();
1704
1705 assert_eq!(result.len(), 1, "Should have exactly one warning");
1707 assert!(
1708 result[0].message.contains("missing.md"),
1709 "Warning should mention missing.md"
1710 );
1711 }
1712
1713 #[test]
1714 fn test_angle_bracket_links_with_parens() {
1715 let temp_dir = tempdir().unwrap();
1717 let base_path = temp_dir.path();
1718
1719 let app_dir = base_path.join("app");
1721 std::fs::create_dir(&app_dir).unwrap();
1722 let upload_dir = app_dir.join("(upload)");
1723 std::fs::create_dir(&upload_dir).unwrap();
1724 let page_file = upload_dir.join("page.tsx");
1725 File::create(&page_file)
1726 .unwrap()
1727 .write_all(b"export default function Page() {}")
1728 .unwrap();
1729
1730 let content = r#"
1732# Test Document with Paths Containing Parens
1733
1734[Upload Page](<app/(upload)/page.tsx>)
1735[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1736[Missing](<app/(missing)/file.md>)
1737"#;
1738
1739 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1740
1741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1742 let result = rule.check(&ctx).unwrap();
1743
1744 assert_eq!(
1746 result.len(),
1747 1,
1748 "Should have exactly one warning for missing file. Got: {result:?}"
1749 );
1750 assert!(
1751 result[0].message.contains("app/(missing)/file.md"),
1752 "Warning should mention app/(missing)/file.md"
1753 );
1754 }
1755
1756 #[test]
1757 fn test_all_file_types_checked() {
1758 let temp_dir = tempdir().unwrap();
1760 let base_path = temp_dir.path();
1761
1762 let content = r#"
1764[Image Link](image.jpg)
1765[Video Link](video.mp4)
1766[Markdown Link](document.md)
1767[PDF Link](file.pdf)
1768"#;
1769
1770 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1771
1772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = rule.check(&ctx).unwrap();
1774
1775 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1777 }
1778
1779 #[test]
1780 fn test_code_span_detection() {
1781 let rule = MD057ExistingRelativeLinks::new();
1782
1783 let temp_dir = tempdir().unwrap();
1785 let base_path = temp_dir.path();
1786
1787 let rule = rule.with_path(base_path);
1788
1789 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1791
1792 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1793 let result = rule.check(&ctx).unwrap();
1794
1795 assert_eq!(result.len(), 1, "Should only flag the real link");
1797 assert!(result[0].message.contains("nonexistent.md"));
1798 }
1799
1800 #[test]
1801 fn test_inline_code_spans() {
1802 let temp_dir = tempdir().unwrap();
1804 let base_path = temp_dir.path();
1805
1806 let content = r#"
1808# Test Document
1809
1810This is a normal link: [Link](missing.md)
1811
1812This is a code span with a link: `[Link](another-missing.md)`
1813
1814Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1815
1816 "#;
1817
1818 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1820
1821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1823 let result = rule.check(&ctx).unwrap();
1824
1825 assert_eq!(result.len(), 1, "Should have exactly one warning");
1827 assert!(
1828 result[0].message.contains("missing.md"),
1829 "Warning should be for missing.md"
1830 );
1831 assert!(
1832 !result.iter().any(|w| w.message.contains("another-missing.md")),
1833 "Should not warn about link in code span"
1834 );
1835 assert!(
1836 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1837 "Should not warn about link in inline code"
1838 );
1839 }
1840
1841 #[test]
1842 fn test_extensionless_link_resolution() {
1843 let temp_dir = tempdir().unwrap();
1845 let base_path = temp_dir.path();
1846
1847 let page_path = base_path.join("page.md");
1849 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1850
1851 let content = r#"
1853# Test Document
1854
1855[Link without extension](page)
1856[Link with extension](page.md)
1857[Missing link](nonexistent)
1858"#;
1859
1860 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1861
1862 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1863 let result = rule.check(&ctx).unwrap();
1864
1865 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1868 assert!(
1869 result[0].message.contains("nonexistent"),
1870 "Warning should be for 'nonexistent' not 'page'"
1871 );
1872 }
1873
1874 #[test]
1876 fn test_cross_file_scope() {
1877 let rule = MD057ExistingRelativeLinks::new();
1878 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1879 }
1880
1881 #[test]
1882 fn test_contribute_to_index_extracts_markdown_links() {
1883 let rule = MD057ExistingRelativeLinks::new();
1884 let content = r#"
1885# Document
1886
1887[Link to docs](./docs/guide.md)
1888[Link with fragment](./other.md#section)
1889[External link](https://example.com)
1890[Image link](image.png)
1891[Media file](video.mp4)
1892"#;
1893
1894 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1895 let mut index = FileIndex::new();
1896 rule.contribute_to_index(&ctx, &mut index);
1897
1898 assert_eq!(index.cross_file_links.len(), 2);
1900
1901 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1903 assert_eq!(index.cross_file_links[0].fragment, "");
1904
1905 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1907 assert_eq!(index.cross_file_links[1].fragment, "section");
1908 }
1909
1910 #[test]
1911 fn test_contribute_to_index_skips_external_and_anchors() {
1912 let rule = MD057ExistingRelativeLinks::new();
1913 let content = r#"
1914# Document
1915
1916[External](https://example.com)
1917[Another external](http://example.org)
1918[Fragment only](#section)
1919[FTP link](ftp://files.example.com)
1920[Mail link](mailto:test@example.com)
1921[WWW link](www.example.com)
1922"#;
1923
1924 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925 let mut index = FileIndex::new();
1926 rule.contribute_to_index(&ctx, &mut index);
1927
1928 assert_eq!(index.cross_file_links.len(), 0);
1930 }
1931
1932 #[test]
1933 fn test_cross_file_check_valid_link() {
1934 use crate::workspace_index::WorkspaceIndex;
1935
1936 let rule = MD057ExistingRelativeLinks::new();
1937
1938 let mut workspace_index = WorkspaceIndex::new();
1940 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1941
1942 let mut file_index = FileIndex::new();
1944 file_index.add_cross_file_link(CrossFileLinkIndex {
1945 target_path: "guide.md".to_string(),
1946 fragment: "".to_string(),
1947 line: 5,
1948 column: 1,
1949 });
1950
1951 let warnings = rule
1953 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1954 .unwrap();
1955
1956 assert!(warnings.is_empty());
1958 }
1959
1960 #[test]
1961 fn test_cross_file_check_missing_link() {
1962 use crate::workspace_index::WorkspaceIndex;
1963
1964 let rule = MD057ExistingRelativeLinks::new();
1965
1966 let workspace_index = WorkspaceIndex::new();
1968
1969 let mut file_index = FileIndex::new();
1971 file_index.add_cross_file_link(CrossFileLinkIndex {
1972 target_path: "missing.md".to_string(),
1973 fragment: "".to_string(),
1974 line: 5,
1975 column: 1,
1976 });
1977
1978 let warnings = rule
1980 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1981 .unwrap();
1982
1983 assert_eq!(warnings.len(), 1);
1985 assert!(warnings[0].message.contains("missing.md"));
1986 assert!(warnings[0].message.contains("does not exist"));
1987 }
1988
1989 #[test]
1990 fn test_cross_file_check_parent_path() {
1991 use crate::workspace_index::WorkspaceIndex;
1992
1993 let rule = MD057ExistingRelativeLinks::new();
1994
1995 let mut workspace_index = WorkspaceIndex::new();
1997 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1998
1999 let mut file_index = FileIndex::new();
2001 file_index.add_cross_file_link(CrossFileLinkIndex {
2002 target_path: "../readme.md".to_string(),
2003 fragment: "".to_string(),
2004 line: 5,
2005 column: 1,
2006 });
2007
2008 let warnings = rule
2010 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2011 .unwrap();
2012
2013 assert!(warnings.is_empty());
2015 }
2016
2017 #[test]
2018 fn test_cross_file_check_html_link_with_md_source() {
2019 use crate::workspace_index::WorkspaceIndex;
2022
2023 let rule = MD057ExistingRelativeLinks::new();
2024
2025 let mut workspace_index = WorkspaceIndex::new();
2027 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2028
2029 let mut file_index = FileIndex::new();
2031 file_index.add_cross_file_link(CrossFileLinkIndex {
2032 target_path: "guide.html".to_string(),
2033 fragment: "section".to_string(),
2034 line: 10,
2035 column: 5,
2036 });
2037
2038 let warnings = rule
2040 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2041 .unwrap();
2042
2043 assert!(
2045 warnings.is_empty(),
2046 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2047 );
2048 }
2049
2050 #[test]
2051 fn test_cross_file_check_html_link_without_source() {
2052 use crate::workspace_index::WorkspaceIndex;
2054
2055 let rule = MD057ExistingRelativeLinks::new();
2056
2057 let workspace_index = WorkspaceIndex::new();
2059
2060 let mut file_index = FileIndex::new();
2062 file_index.add_cross_file_link(CrossFileLinkIndex {
2063 target_path: "missing.html".to_string(),
2064 fragment: "".to_string(),
2065 line: 10,
2066 column: 5,
2067 });
2068
2069 let warnings = rule
2071 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2072 .unwrap();
2073
2074 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
2076 assert!(warnings[0].message.contains("missing.html"));
2077 }
2078
2079 #[test]
2080 fn test_normalize_path_function() {
2081 assert_eq!(
2083 normalize_path(Path::new("docs/guide.md")),
2084 PathBuf::from("docs/guide.md")
2085 );
2086
2087 assert_eq!(
2089 normalize_path(Path::new("./docs/guide.md")),
2090 PathBuf::from("docs/guide.md")
2091 );
2092
2093 assert_eq!(
2095 normalize_path(Path::new("docs/sub/../guide.md")),
2096 PathBuf::from("docs/guide.md")
2097 );
2098
2099 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2101 }
2102
2103 #[test]
2104 fn test_html_link_with_md_source() {
2105 let temp_dir = tempdir().unwrap();
2107 let base_path = temp_dir.path();
2108
2109 let md_file = base_path.join("guide.md");
2111 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2112
2113 let content = r#"
2114[Read the guide](guide.html)
2115[Also here](getting-started.html)
2116"#;
2117
2118 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2119 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2120 let result = rule.check(&ctx).unwrap();
2121
2122 assert_eq!(
2124 result.len(),
2125 1,
2126 "Should only warn about missing source. Got: {result:?}"
2127 );
2128 assert!(result[0].message.contains("getting-started.html"));
2129 }
2130
2131 #[test]
2132 fn test_htm_link_with_md_source() {
2133 let temp_dir = tempdir().unwrap();
2135 let base_path = temp_dir.path();
2136
2137 let md_file = base_path.join("page.md");
2138 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2139
2140 let content = "[Page](page.htm)";
2141
2142 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2143 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2144 let result = rule.check(&ctx).unwrap();
2145
2146 assert!(
2147 result.is_empty(),
2148 "Should not warn when .md source exists for .htm link"
2149 );
2150 }
2151
2152 #[test]
2153 fn test_html_link_finds_various_markdown_extensions() {
2154 let temp_dir = tempdir().unwrap();
2156 let base_path = temp_dir.path();
2157
2158 File::create(base_path.join("doc.md")).unwrap();
2159 File::create(base_path.join("tutorial.mdx")).unwrap();
2160 File::create(base_path.join("guide.markdown")).unwrap();
2161
2162 let content = r#"
2163[Doc](doc.html)
2164[Tutorial](tutorial.html)
2165[Guide](guide.html)
2166"#;
2167
2168 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2169 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2170 let result = rule.check(&ctx).unwrap();
2171
2172 assert!(
2173 result.is_empty(),
2174 "Should find all markdown variants as source files. Got: {result:?}"
2175 );
2176 }
2177
2178 #[test]
2179 fn test_html_link_in_subdirectory() {
2180 let temp_dir = tempdir().unwrap();
2182 let base_path = temp_dir.path();
2183
2184 let docs_dir = base_path.join("docs");
2185 std::fs::create_dir(&docs_dir).unwrap();
2186 File::create(docs_dir.join("guide.md"))
2187 .unwrap()
2188 .write_all(b"# Guide")
2189 .unwrap();
2190
2191 let content = "[Guide](docs/guide.html)";
2192
2193 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2194 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2195 let result = rule.check(&ctx).unwrap();
2196
2197 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2198 }
2199
2200 #[test]
2201 fn test_absolute_path_skipped_in_check() {
2202 let temp_dir = tempdir().unwrap();
2205 let base_path = temp_dir.path();
2206
2207 let content = r#"
2208# Test Document
2209
2210[Go Runtime](/pkg/runtime)
2211[Go Runtime with Fragment](/pkg/runtime#section)
2212[API Docs](/api/v1/users)
2213[Blog Post](/blog/2024/release.html)
2214[React Hook](/react/hooks/use-state.html)
2215"#;
2216
2217 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2218 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2219 let result = rule.check(&ctx).unwrap();
2220
2221 assert!(
2223 result.is_empty(),
2224 "Absolute paths should be skipped. Got warnings: {result:?}"
2225 );
2226 }
2227
2228 #[test]
2229 fn test_absolute_path_skipped_in_cross_file_check() {
2230 use crate::workspace_index::WorkspaceIndex;
2232
2233 let rule = MD057ExistingRelativeLinks::new();
2234
2235 let workspace_index = WorkspaceIndex::new();
2237
2238 let mut file_index = FileIndex::new();
2240 file_index.add_cross_file_link(CrossFileLinkIndex {
2241 target_path: "/pkg/runtime.md".to_string(),
2242 fragment: "".to_string(),
2243 line: 5,
2244 column: 1,
2245 });
2246 file_index.add_cross_file_link(CrossFileLinkIndex {
2247 target_path: "/api/v1/users.md".to_string(),
2248 fragment: "section".to_string(),
2249 line: 10,
2250 column: 1,
2251 });
2252
2253 let warnings = rule
2255 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2256 .unwrap();
2257
2258 assert!(
2260 warnings.is_empty(),
2261 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2262 );
2263 }
2264
2265 #[test]
2266 fn test_protocol_relative_url_not_skipped() {
2267 let temp_dir = tempdir().unwrap();
2270 let base_path = temp_dir.path();
2271
2272 let content = r#"
2273# Test Document
2274
2275[External](//example.com/page)
2276[Another](//cdn.example.com/asset.js)
2277"#;
2278
2279 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2280 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2281 let result = rule.check(&ctx).unwrap();
2282
2283 assert!(
2285 result.is_empty(),
2286 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2287 );
2288 }
2289
2290 #[test]
2291 fn test_email_addresses_skipped() {
2292 let temp_dir = tempdir().unwrap();
2295 let base_path = temp_dir.path();
2296
2297 let content = r#"
2298# Test Document
2299
2300[Contact](user@example.com)
2301[Steering](steering@kubernetes.io)
2302[Support](john.doe+filter@company.co.uk)
2303[User](user_name@sub.domain.com)
2304"#;
2305
2306 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2307 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2308 let result = rule.check(&ctx).unwrap();
2309
2310 assert!(
2312 result.is_empty(),
2313 "Email addresses should be skipped. Got warnings: {result:?}"
2314 );
2315 }
2316
2317 #[test]
2318 fn test_email_addresses_vs_file_paths() {
2319 let temp_dir = tempdir().unwrap();
2322 let base_path = temp_dir.path();
2323
2324 let content = r#"
2325# Test Document
2326
2327[Email](user@example.com) <!-- Should be skipped (email) -->
2328[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2329[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2330"#;
2331
2332 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2333 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2334 let result = rule.check(&ctx).unwrap();
2335
2336 assert!(
2338 result.is_empty(),
2339 "All email addresses should be skipped. Got: {result:?}"
2340 );
2341 }
2342
2343 #[test]
2344 fn test_diagnostic_position_accuracy() {
2345 let temp_dir = tempdir().unwrap();
2347 let base_path = temp_dir.path();
2348
2349 let content = "prefix [text](missing.md) suffix";
2352 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2356 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2357 let result = rule.check(&ctx).unwrap();
2358
2359 assert_eq!(result.len(), 1, "Should have exactly one warning");
2360 assert_eq!(result[0].line, 1, "Should be on line 1");
2361 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2362 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2363 }
2364
2365 #[test]
2366 fn test_diagnostic_position_angle_brackets() {
2367 let temp_dir = tempdir().unwrap();
2369 let base_path = temp_dir.path();
2370
2371 let content = "[link](<missing.md>)";
2374 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2377 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2378 let result = rule.check(&ctx).unwrap();
2379
2380 assert_eq!(result.len(), 1, "Should have exactly one warning");
2381 assert_eq!(result[0].line, 1, "Should be on line 1");
2382 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2383 }
2384
2385 #[test]
2386 fn test_diagnostic_position_multiline() {
2387 let temp_dir = tempdir().unwrap();
2389 let base_path = temp_dir.path();
2390
2391 let content = r#"# Title
2392Some text on line 2
2393[link on line 3](missing1.md)
2394More text
2395[link on line 5](missing2.md)"#;
2396
2397 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2398 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2399 let result = rule.check(&ctx).unwrap();
2400
2401 assert_eq!(result.len(), 2, "Should have two warnings");
2402
2403 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2405 assert!(result[0].message.contains("missing1.md"));
2406
2407 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2409 assert!(result[1].message.contains("missing2.md"));
2410 }
2411
2412 #[test]
2413 fn test_diagnostic_position_with_spaces() {
2414 let temp_dir = tempdir().unwrap();
2416 let base_path = temp_dir.path();
2417
2418 let content = "[link]( missing.md )";
2419 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2424 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2425 let result = rule.check(&ctx).unwrap();
2426
2427 assert_eq!(result.len(), 1, "Should have exactly one warning");
2428 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2430 }
2431
2432 #[test]
2433 fn test_diagnostic_position_image() {
2434 let temp_dir = tempdir().unwrap();
2436 let base_path = temp_dir.path();
2437
2438 let content = "";
2439
2440 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2441 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442 let result = rule.check(&ctx).unwrap();
2443
2444 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2445 assert_eq!(result[0].line, 1);
2446 assert!(result[0].column > 0, "Should have valid column position");
2448 assert!(result[0].message.contains("missing.jpg"));
2449 }
2450
2451 #[test]
2452 fn test_wikilinks_skipped() {
2453 let temp_dir = tempdir().unwrap();
2456 let base_path = temp_dir.path();
2457
2458 let content = r#"# Test Document
2459
2460[[Microsoft#Windows OS]]
2461[[SomePage]]
2462[[Page With Spaces]]
2463[[path/to/page#section]]
2464[[page|Display Text]]
2465
2466This is a [real missing link](missing.md) that should be flagged.
2467"#;
2468
2469 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2470 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471 let result = rule.check(&ctx).unwrap();
2472
2473 assert_eq!(
2475 result.len(),
2476 1,
2477 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2478 );
2479 assert!(
2480 result[0].message.contains("missing.md"),
2481 "Warning should be for missing.md, not wikilinks"
2482 );
2483 }
2484
2485 #[test]
2486 fn test_wikilinks_not_added_to_index() {
2487 let temp_dir = tempdir().unwrap();
2489 let base_path = temp_dir.path();
2490
2491 let content = r#"# Test Document
2492
2493[[Microsoft#Windows OS]]
2494[[SomePage#section]]
2495[Regular Link](other.md)
2496"#;
2497
2498 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2499 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2500
2501 let mut file_index = FileIndex::new();
2502 rule.contribute_to_index(&ctx, &mut file_index);
2503
2504 let cross_file_links = &file_index.cross_file_links;
2507 assert_eq!(
2508 cross_file_links.len(),
2509 1,
2510 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2511 );
2512 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2513 }
2514
2515 #[test]
2516 fn test_reference_definition_missing_file() {
2517 let temp_dir = tempdir().unwrap();
2519 let base_path = temp_dir.path();
2520
2521 let content = r#"# Test Document
2522
2523[test]: ./missing.md
2524[example]: ./nonexistent.html
2525
2526Use [test] and [example] here.
2527"#;
2528
2529 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2530 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2531 let result = rule.check(&ctx).unwrap();
2532
2533 assert_eq!(
2535 result.len(),
2536 2,
2537 "Should have warnings for missing reference definition targets. Got: {result:?}"
2538 );
2539 assert!(
2540 result.iter().any(|w| w.message.contains("missing.md")),
2541 "Should warn about missing.md"
2542 );
2543 assert!(
2544 result.iter().any(|w| w.message.contains("nonexistent.html")),
2545 "Should warn about nonexistent.html"
2546 );
2547 }
2548
2549 #[test]
2550 fn test_reference_definition_existing_file() {
2551 let temp_dir = tempdir().unwrap();
2553 let base_path = temp_dir.path();
2554
2555 let exists_path = base_path.join("exists.md");
2557 File::create(&exists_path)
2558 .unwrap()
2559 .write_all(b"# Existing file")
2560 .unwrap();
2561
2562 let content = r#"# Test Document
2563
2564[test]: ./exists.md
2565
2566Use [test] here.
2567"#;
2568
2569 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2570 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2571 let result = rule.check(&ctx).unwrap();
2572
2573 assert!(
2575 result.is_empty(),
2576 "Should not warn about existing file. Got: {result:?}"
2577 );
2578 }
2579
2580 #[test]
2581 fn test_reference_definition_external_url_skipped() {
2582 let temp_dir = tempdir().unwrap();
2584 let base_path = temp_dir.path();
2585
2586 let content = r#"# Test Document
2587
2588[google]: https://google.com
2589[example]: http://example.org
2590[mail]: mailto:test@example.com
2591[ftp]: ftp://files.example.com
2592[local]: ./missing.md
2593
2594Use [google], [example], [mail], [ftp], [local] here.
2595"#;
2596
2597 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2598 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2599 let result = rule.check(&ctx).unwrap();
2600
2601 assert_eq!(
2603 result.len(),
2604 1,
2605 "Should only warn about local missing file. Got: {result:?}"
2606 );
2607 assert!(
2608 result[0].message.contains("missing.md"),
2609 "Warning should be for missing.md"
2610 );
2611 }
2612
2613 #[test]
2614 fn test_reference_definition_fragment_only_skipped() {
2615 let temp_dir = tempdir().unwrap();
2617 let base_path = temp_dir.path();
2618
2619 let content = r#"# Test Document
2620
2621[section]: #my-section
2622
2623Use [section] here.
2624"#;
2625
2626 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2628 let result = rule.check(&ctx).unwrap();
2629
2630 assert!(
2632 result.is_empty(),
2633 "Should not warn about fragment-only reference. Got: {result:?}"
2634 );
2635 }
2636
2637 #[test]
2638 fn test_reference_definition_column_position() {
2639 let temp_dir = tempdir().unwrap();
2641 let base_path = temp_dir.path();
2642
2643 let content = "[ref]: ./missing.md";
2646 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2650 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2651 let result = rule.check(&ctx).unwrap();
2652
2653 assert_eq!(result.len(), 1, "Should have exactly one warning");
2654 assert_eq!(result[0].line, 1, "Should be on line 1");
2655 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2656 }
2657
2658 #[test]
2659 fn test_reference_definition_html_with_md_source() {
2660 let temp_dir = tempdir().unwrap();
2662 let base_path = temp_dir.path();
2663
2664 let md_file = base_path.join("guide.md");
2666 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2667
2668 let content = r#"# Test Document
2669
2670[guide]: ./guide.html
2671[missing]: ./missing.html
2672
2673Use [guide] and [missing] here.
2674"#;
2675
2676 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2678 let result = rule.check(&ctx).unwrap();
2679
2680 assert_eq!(
2682 result.len(),
2683 1,
2684 "Should only warn about missing source. Got: {result:?}"
2685 );
2686 assert!(result[0].message.contains("missing.html"));
2687 }
2688
2689 #[test]
2690 fn test_reference_definition_url_encoded() {
2691 let temp_dir = tempdir().unwrap();
2693 let base_path = temp_dir.path();
2694
2695 let file_with_spaces = base_path.join("file with spaces.md");
2697 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2698
2699 let content = r#"# Test Document
2700
2701[spaces]: ./file%20with%20spaces.md
2702[missing]: ./missing%20file.md
2703
2704Use [spaces] and [missing] here.
2705"#;
2706
2707 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2708 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2709 let result = rule.check(&ctx).unwrap();
2710
2711 assert_eq!(
2713 result.len(),
2714 1,
2715 "Should only warn about missing URL-encoded file. Got: {result:?}"
2716 );
2717 assert!(result[0].message.contains("missing%20file.md"));
2718 }
2719
2720 #[test]
2721 fn test_inline_and_reference_both_checked() {
2722 let temp_dir = tempdir().unwrap();
2724 let base_path = temp_dir.path();
2725
2726 let content = r#"# Test Document
2727
2728[inline link](./inline-missing.md)
2729[ref]: ./ref-missing.md
2730
2731Use [ref] here.
2732"#;
2733
2734 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736 let result = rule.check(&ctx).unwrap();
2737
2738 assert_eq!(
2740 result.len(),
2741 2,
2742 "Should warn about both inline and reference links. Got: {result:?}"
2743 );
2744 assert!(
2745 result.iter().any(|w| w.message.contains("inline-missing.md")),
2746 "Should warn about inline-missing.md"
2747 );
2748 assert!(
2749 result.iter().any(|w| w.message.contains("ref-missing.md")),
2750 "Should warn about ref-missing.md"
2751 );
2752 }
2753
2754 #[test]
2755 fn test_footnote_definitions_not_flagged() {
2756 let rule = MD057ExistingRelativeLinks::default();
2759
2760 let content = r#"# Title
2761
2762A footnote[^1].
2763
2764[^1]: [link](https://www.google.com).
2765"#;
2766
2767 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2768 let result = rule.check(&ctx).unwrap();
2769
2770 assert!(
2771 result.is_empty(),
2772 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2773 );
2774 }
2775
2776 #[test]
2777 fn test_footnote_with_relative_link_inside() {
2778 let rule = MD057ExistingRelativeLinks::default();
2781
2782 let content = r#"# Title
2783
2784See the footnote[^1].
2785
2786[^1]: Check out [this file](./existing.md) for more info.
2787[^2]: Also see [missing](./does-not-exist.md).
2788"#;
2789
2790 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791 let result = rule.check(&ctx).unwrap();
2792
2793 for warning in &result {
2798 assert!(
2799 !warning.message.contains("[this file]"),
2800 "Footnote content should not be treated as URL: {warning:?}"
2801 );
2802 assert!(
2803 !warning.message.contains("[missing]"),
2804 "Footnote content should not be treated as URL: {warning:?}"
2805 );
2806 }
2807 }
2808
2809 #[test]
2810 fn test_mixed_footnotes_and_reference_definitions() {
2811 let temp_dir = tempdir().unwrap();
2813 let base_path = temp_dir.path();
2814
2815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2816
2817 let content = r#"# Title
2818
2819A footnote[^1] and a [ref link][myref].
2820
2821[^1]: This is a footnote with [link](https://example.com).
2822
2823[myref]: ./missing-file.md "This should be checked"
2824"#;
2825
2826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2827 let result = rule.check(&ctx).unwrap();
2828
2829 assert_eq!(
2831 result.len(),
2832 1,
2833 "Should only warn about the regular reference definition. Got: {result:?}"
2834 );
2835 assert!(
2836 result[0].message.contains("missing-file.md"),
2837 "Should warn about missing-file.md in reference definition"
2838 );
2839 }
2840
2841 #[test]
2842 fn test_absolute_links_ignore_by_default() {
2843 let temp_dir = tempdir().unwrap();
2845 let base_path = temp_dir.path();
2846
2847 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2848
2849 let content = r#"# Links
2850
2851[API docs](/api/v1/users)
2852[Blog post](/blog/2024/release.html)
2853
2854
2855[ref]: /docs/reference.md
2856"#;
2857
2858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let result = rule.check(&ctx).unwrap();
2860
2861 assert!(
2863 result.is_empty(),
2864 "Absolute links should be ignored by default. Got: {result:?}"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_absolute_links_warn_config() {
2870 let temp_dir = tempdir().unwrap();
2872 let base_path = temp_dir.path();
2873
2874 let config = MD057Config {
2875 absolute_links: AbsoluteLinksOption::Warn,
2876 ..Default::default()
2877 };
2878 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2879
2880 let content = r#"# Links
2881
2882[API docs](/api/v1/users)
2883[Blog post](/blog/2024/release.html)
2884"#;
2885
2886 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2887 let result = rule.check(&ctx).unwrap();
2888
2889 assert_eq!(
2891 result.len(),
2892 2,
2893 "Should warn about both absolute links. Got: {result:?}"
2894 );
2895 assert!(
2896 result[0].message.contains("cannot be validated locally"),
2897 "Warning should explain why: {}",
2898 result[0].message
2899 );
2900 assert!(
2901 result[0].message.contains("/api/v1/users"),
2902 "Warning should include the link path"
2903 );
2904 }
2905
2906 #[test]
2907 fn test_absolute_links_warn_images() {
2908 let temp_dir = tempdir().unwrap();
2910 let base_path = temp_dir.path();
2911
2912 let config = MD057Config {
2913 absolute_links: AbsoluteLinksOption::Warn,
2914 ..Default::default()
2915 };
2916 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2917
2918 let content = r#"# Images
2919
2920
2921"#;
2922
2923 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2924 let result = rule.check(&ctx).unwrap();
2925
2926 assert_eq!(
2927 result.len(),
2928 1,
2929 "Should warn about absolute image path. Got: {result:?}"
2930 );
2931 assert!(
2932 result[0].message.contains("/assets/logo.png"),
2933 "Warning should include the image path"
2934 );
2935 }
2936
2937 #[test]
2938 fn test_absolute_links_warn_reference_definitions() {
2939 let temp_dir = tempdir().unwrap();
2941 let base_path = temp_dir.path();
2942
2943 let config = MD057Config {
2944 absolute_links: AbsoluteLinksOption::Warn,
2945 ..Default::default()
2946 };
2947 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2948
2949 let content = r#"# Reference
2950
2951See the [docs][ref].
2952
2953[ref]: /docs/reference.md
2954"#;
2955
2956 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2957 let result = rule.check(&ctx).unwrap();
2958
2959 assert_eq!(
2960 result.len(),
2961 1,
2962 "Should warn about absolute reference definition. Got: {result:?}"
2963 );
2964 assert!(
2965 result[0].message.contains("/docs/reference.md"),
2966 "Warning should include the reference path"
2967 );
2968 }
2969
2970 #[test]
2971 fn test_search_paths_inline_link() {
2972 let temp_dir = tempdir().unwrap();
2973 let base_path = temp_dir.path();
2974
2975 let assets_dir = base_path.join("assets");
2977 std::fs::create_dir_all(&assets_dir).unwrap();
2978 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
2979
2980 let config = MD057Config {
2981 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
2982 ..Default::default()
2983 };
2984 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2985
2986 let content = "# Test\n\n[Photo](photo.png)\n";
2987 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988 let result = rule.check(&ctx).unwrap();
2989
2990 assert!(
2991 result.is_empty(),
2992 "Should find photo.png via search-paths. Got: {result:?}"
2993 );
2994 }
2995
2996 #[test]
2997 fn test_search_paths_image() {
2998 let temp_dir = tempdir().unwrap();
2999 let base_path = temp_dir.path();
3000
3001 let assets_dir = base_path.join("attachments");
3002 std::fs::create_dir_all(&assets_dir).unwrap();
3003 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3004
3005 let config = MD057Config {
3006 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3007 ..Default::default()
3008 };
3009 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3010
3011 let content = "# Test\n\n\n";
3012 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3013 let result = rule.check(&ctx).unwrap();
3014
3015 assert!(
3016 result.is_empty(),
3017 "Should find diagram.svg via search-paths. Got: {result:?}"
3018 );
3019 }
3020
3021 #[test]
3022 fn test_search_paths_reference_definition() {
3023 let temp_dir = tempdir().unwrap();
3024 let base_path = temp_dir.path();
3025
3026 let assets_dir = base_path.join("images");
3027 std::fs::create_dir_all(&assets_dir).unwrap();
3028 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3029
3030 let config = MD057Config {
3031 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3032 ..Default::default()
3033 };
3034 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3035
3036 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039
3040 assert!(
3041 result.is_empty(),
3042 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3043 );
3044 }
3045
3046 #[test]
3047 fn test_search_paths_still_warns_when_truly_missing() {
3048 let temp_dir = tempdir().unwrap();
3049 let base_path = temp_dir.path();
3050
3051 let assets_dir = base_path.join("assets");
3052 std::fs::create_dir_all(&assets_dir).unwrap();
3053
3054 let config = MD057Config {
3055 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3056 ..Default::default()
3057 };
3058 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3059
3060 let content = "# Test\n\n\n";
3061 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3062 let result = rule.check(&ctx).unwrap();
3063
3064 assert_eq!(
3065 result.len(),
3066 1,
3067 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_search_paths_nonexistent_directory() {
3073 let temp_dir = tempdir().unwrap();
3074 let base_path = temp_dir.path();
3075
3076 let config = MD057Config {
3077 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3078 ..Default::default()
3079 };
3080 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3081
3082 let content = "# Test\n\n\n";
3083 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3084 let result = rule.check(&ctx).unwrap();
3085
3086 assert_eq!(
3087 result.len(),
3088 1,
3089 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3090 );
3091 }
3092
3093 #[test]
3094 fn test_obsidian_attachment_folder_named() {
3095 let temp_dir = tempdir().unwrap();
3096 let vault = temp_dir.path().join("vault");
3097 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3098 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3099 std::fs::create_dir_all(vault.join("notes")).unwrap();
3100
3101 std::fs::write(
3102 vault.join(".obsidian/app.json"),
3103 r#"{"attachmentFolderPath": "Attachments"}"#,
3104 )
3105 .unwrap();
3106 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3107
3108 let notes_dir = vault.join("notes");
3109 let source_file = notes_dir.join("test.md");
3110 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3111
3112 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3113
3114 let content = "# Test\n\n\n";
3115 let ctx =
3116 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3117 let result = rule.check(&ctx).unwrap();
3118
3119 assert!(
3120 result.is_empty(),
3121 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3122 );
3123 }
3124
3125 #[test]
3126 fn test_obsidian_attachment_same_folder_as_file() {
3127 let temp_dir = tempdir().unwrap();
3128 let vault = temp_dir.path().join("vault-rf");
3129 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3130 std::fs::create_dir_all(vault.join("notes")).unwrap();
3131
3132 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3133
3134 let notes_dir = vault.join("notes");
3136 let source_file = notes_dir.join("test.md");
3137 std::fs::write(&source_file, "placeholder").unwrap();
3138 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3139
3140 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3141
3142 let content = "# Test\n\n\n";
3143 let ctx =
3144 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3145 let result = rule.check(&ctx).unwrap();
3146
3147 assert!(
3148 result.is_empty(),
3149 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3150 );
3151 }
3152
3153 #[test]
3154 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3155 let temp_dir = tempdir().unwrap();
3156 let vault = temp_dir.path().join("vault-nf");
3157 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3158 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3159 std::fs::create_dir_all(vault.join("notes")).unwrap();
3160
3161 std::fs::write(
3162 vault.join(".obsidian/app.json"),
3163 r#"{"attachmentFolderPath": "Attachments"}"#,
3164 )
3165 .unwrap();
3166 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3167
3168 let notes_dir = vault.join("notes");
3169 let source_file = notes_dir.join("test.md");
3170 std::fs::write(&source_file, "placeholder").unwrap();
3171
3172 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3173
3174 let content = "# Test\n\n\n";
3175 let ctx =
3177 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3178 let result = rule.check(&ctx).unwrap();
3179
3180 assert_eq!(
3181 result.len(),
3182 1,
3183 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3184 );
3185 }
3186
3187 #[test]
3188 fn test_search_paths_combined_with_obsidian() {
3189 let temp_dir = tempdir().unwrap();
3190 let vault = temp_dir.path().join("vault-combo");
3191 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3192 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3193 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3194 std::fs::create_dir_all(vault.join("notes")).unwrap();
3195
3196 std::fs::write(
3197 vault.join(".obsidian/app.json"),
3198 r#"{"attachmentFolderPath": "Attachments"}"#,
3199 )
3200 .unwrap();
3201 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3202 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3203
3204 let notes_dir = vault.join("notes");
3205 let source_file = notes_dir.join("test.md");
3206 std::fs::write(&source_file, "placeholder").unwrap();
3207
3208 let extra_assets_dir = vault.join("extra-assets");
3209 let config = MD057Config {
3210 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3211 ..Default::default()
3212 };
3213 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3214
3215 let content = "# Test\n\n\n\n\n";
3217 let ctx =
3218 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3219 let result = rule.check(&ctx).unwrap();
3220
3221 assert!(
3222 result.is_empty(),
3223 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3224 );
3225 }
3226
3227 #[test]
3228 fn test_obsidian_attachment_subfolder_under_file() {
3229 let temp_dir = tempdir().unwrap();
3230 let vault = temp_dir.path().join("vault-sub");
3231 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3232 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3233
3234 std::fs::write(
3235 vault.join(".obsidian/app.json"),
3236 r#"{"attachmentFolderPath": "./assets"}"#,
3237 )
3238 .unwrap();
3239 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3240
3241 let notes_dir = vault.join("notes");
3242 let source_file = notes_dir.join("test.md");
3243 std::fs::write(&source_file, "placeholder").unwrap();
3244
3245 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3246
3247 let content = "# Test\n\n\n";
3248 let ctx =
3249 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3250 let result = rule.check(&ctx).unwrap();
3251
3252 assert!(
3253 result.is_empty(),
3254 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3255 );
3256 }
3257
3258 #[test]
3259 fn test_obsidian_attachment_vault_root() {
3260 let temp_dir = tempdir().unwrap();
3261 let vault = temp_dir.path().join("vault-root");
3262 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3263 std::fs::create_dir_all(vault.join("notes")).unwrap();
3264
3265 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3267 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3268
3269 let notes_dir = vault.join("notes");
3270 let source_file = notes_dir.join("test.md");
3271 std::fs::write(&source_file, "placeholder").unwrap();
3272
3273 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3274
3275 let content = "# Test\n\n\n";
3276 let ctx =
3277 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3278 let result = rule.check(&ctx).unwrap();
3279
3280 assert!(
3281 result.is_empty(),
3282 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3283 );
3284 }
3285
3286 #[test]
3287 fn test_search_paths_multiple_directories() {
3288 let temp_dir = tempdir().unwrap();
3289 let base_path = temp_dir.path();
3290
3291 let dir_a = base_path.join("dir-a");
3292 let dir_b = base_path.join("dir-b");
3293 std::fs::create_dir_all(&dir_a).unwrap();
3294 std::fs::create_dir_all(&dir_b).unwrap();
3295 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3296 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3297
3298 let config = MD057Config {
3299 search_paths: vec![
3300 dir_a.to_string_lossy().into_owned(),
3301 dir_b.to_string_lossy().into_owned(),
3302 ],
3303 ..Default::default()
3304 };
3305 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3306
3307 let content = "# Test\n\n\n\n\n";
3308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3309 let result = rule.check(&ctx).unwrap();
3310
3311 assert!(
3312 result.is_empty(),
3313 "Should find files across multiple search paths. Got: {result:?}"
3314 );
3315 }
3316
3317 #[test]
3318 fn test_cross_file_check_with_search_paths() {
3319 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3320
3321 let temp_dir = tempdir().unwrap();
3322 let base_path = temp_dir.path();
3323
3324 let docs_dir = base_path.join("docs");
3326 std::fs::create_dir_all(&docs_dir).unwrap();
3327 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3328
3329 let config = MD057Config {
3330 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3331 ..Default::default()
3332 };
3333 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3334
3335 let file_path = base_path.join("README.md");
3336 std::fs::write(&file_path, "# Readme\n").unwrap();
3337
3338 let mut file_index = FileIndex::default();
3339 file_index.cross_file_links.push(CrossFileLinkIndex {
3340 target_path: "guide.md".to_string(),
3341 fragment: String::new(),
3342 line: 3,
3343 column: 1,
3344 });
3345
3346 let workspace_index = WorkspaceIndex::new();
3347
3348 let result = rule
3349 .cross_file_check(&file_path, &file_index, &workspace_index)
3350 .unwrap();
3351
3352 assert!(
3353 result.is_empty(),
3354 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_cross_file_check_with_obsidian_flavor() {
3360 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3361
3362 let temp_dir = tempdir().unwrap();
3363 let vault = temp_dir.path().join("vault-xf");
3364 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3365 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3366 std::fs::create_dir_all(vault.join("notes")).unwrap();
3367
3368 std::fs::write(
3369 vault.join(".obsidian/app.json"),
3370 r#"{"attachmentFolderPath": "Attachments"}"#,
3371 )
3372 .unwrap();
3373 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3374
3375 let notes_dir = vault.join("notes");
3376 let file_path = notes_dir.join("test.md");
3377 std::fs::write(&file_path, "placeholder").unwrap();
3378
3379 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3380 .with_path(¬es_dir)
3381 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3382
3383 let mut file_index = FileIndex::default();
3384 file_index.cross_file_links.push(CrossFileLinkIndex {
3385 target_path: "ref.md".to_string(),
3386 fragment: String::new(),
3387 line: 3,
3388 column: 1,
3389 });
3390
3391 let workspace_index = WorkspaceIndex::new();
3392
3393 let result = rule
3394 .cross_file_check(&file_path, &file_index, &workspace_index)
3395 .unwrap();
3396
3397 assert!(
3398 result.is_empty(),
3399 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3400 );
3401 }
3402
3403 #[test]
3404 fn test_cross_file_check_clears_stale_cache() {
3405 use crate::workspace_index::WorkspaceIndex;
3408
3409 let rule = MD057ExistingRelativeLinks::new();
3410
3411 {
3414 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3415 cache.insert(PathBuf::from("docs/phantom.md"), true);
3416 }
3417
3418 let workspace_index = WorkspaceIndex::new();
3419
3420 let mut file_index = FileIndex::new();
3421 file_index.add_cross_file_link(CrossFileLinkIndex {
3422 target_path: "phantom.md".to_string(),
3423 fragment: "".to_string(),
3424 line: 1,
3425 column: 1,
3426 });
3427
3428 let warnings = rule
3429 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3430 .unwrap();
3431
3432 assert_eq!(
3434 warnings.len(),
3435 1,
3436 "cross_file_check should report missing file after clearing stale cache. Got: {warnings:?}"
3437 );
3438 assert!(warnings[0].message.contains("phantom.md"));
3439 }
3440
3441 #[test]
3442 fn test_cross_file_check_does_not_carry_over_cache_between_runs() {
3443 use crate::workspace_index::WorkspaceIndex;
3445
3446 let rule = MD057ExistingRelativeLinks::new();
3447 let workspace_index = WorkspaceIndex::new();
3448
3449 let mut file_index_1 = FileIndex::new();
3451 file_index_1.add_cross_file_link(CrossFileLinkIndex {
3452 target_path: "nonexistent.md".to_string(),
3453 fragment: "".to_string(),
3454 line: 1,
3455 column: 1,
3456 });
3457
3458 let warnings_1 = rule
3459 .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3460 .unwrap();
3461 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3462
3463 {
3465 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3466 cache.insert(PathBuf::from("docs/nonexistent.md"), true);
3467 }
3468
3469 let warnings_2 = rule
3471 .cross_file_check(Path::new("docs/a.md"), &file_index_1, &workspace_index)
3472 .unwrap();
3473
3474 assert_eq!(
3476 warnings_2.len(),
3477 1,
3478 "Second run should still detect missing file after cache reset. Got: {warnings_2:?}"
3479 );
3480 }
3481}