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 let mut warnings = Vec::new();
1069
1070 let file_dir = file_path.parent();
1072
1073 let base_path = file_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| CURRENT_DIR.clone());
1075 let extra_search_paths = self.compute_search_paths(self.flavor, Some(file_path), &base_path);
1076
1077 for cross_link in &file_index.cross_file_links {
1078 let decoded_target = Self::url_decode(&cross_link.target_path);
1081
1082 if decoded_target.starts_with('/') {
1086 continue;
1087 }
1088
1089 let target_path = if let Some(dir) = file_dir {
1091 dir.join(&decoded_target)
1092 } else {
1093 Path::new(&decoded_target).to_path_buf()
1094 };
1095
1096 let target_path = normalize_path(&target_path);
1098
1099 let file_exists =
1101 workspace_index.contains_file(&target_path) || file_exists_or_markdown_extension(&target_path);
1102
1103 if !file_exists {
1104 let has_md_source = if let Some(ext) = target_path.extension().and_then(|e| e.to_str())
1107 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1108 && let (Some(stem), Some(parent)) =
1109 (target_path.file_stem().and_then(|s| s.to_str()), target_path.parent())
1110 {
1111 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1112 let source_path = parent.join(format!("{stem}{md_ext}"));
1113 workspace_index.contains_file(&source_path) || source_path.exists()
1114 })
1115 } else {
1116 false
1117 };
1118
1119 if !has_md_source && !Self::exists_in_search_paths(&decoded_target, &extra_search_paths) {
1120 warnings.push(LintWarning {
1121 rule_name: Some(self.name().to_string()),
1122 line: cross_link.line,
1123 column: cross_link.column,
1124 end_line: cross_link.line,
1125 end_column: cross_link.column + cross_link.target_path.len(),
1126 message: format!("Relative link '{}' does not exist", cross_link.target_path),
1127 severity: Severity::Error,
1128 fix: None,
1129 });
1130 }
1131 }
1132 }
1133
1134 Ok(warnings)
1135 }
1136}
1137
1138fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1143 let from_components: Vec<_> = from_dir.components().collect();
1144 let to_components: Vec<_> = to_path.components().collect();
1145
1146 let common_len = from_components
1148 .iter()
1149 .zip(to_components.iter())
1150 .take_while(|(a, b)| a == b)
1151 .count();
1152
1153 let mut result = PathBuf::new();
1154
1155 for _ in common_len..from_components.len() {
1157 result.push("..");
1158 }
1159
1160 for component in &to_components[common_len..] {
1162 result.push(component);
1163 }
1164
1165 result
1166}
1167
1168fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1174 let link_path = Path::new(raw_link_path);
1175
1176 let has_traversal = link_path
1178 .components()
1179 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1180
1181 if !has_traversal {
1182 return None;
1183 }
1184
1185 let combined = source_dir.join(link_path);
1187 let normalized_target = normalize_path(&combined);
1188
1189 let normalized_source = normalize_path(source_dir);
1191 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1192
1193 if shortest != link_path {
1195 let compact = shortest.to_string_lossy().to_string();
1196 if compact.is_empty() {
1198 return None;
1199 }
1200 Some(compact.replace('\\', "/"))
1202 } else {
1203 None
1204 }
1205}
1206
1207fn normalize_path(path: &Path) -> PathBuf {
1209 let mut components = Vec::new();
1210
1211 for component in path.components() {
1212 match component {
1213 std::path::Component::ParentDir => {
1214 if !components.is_empty() {
1216 components.pop();
1217 }
1218 }
1219 std::path::Component::CurDir => {
1220 }
1222 _ => {
1223 components.push(component);
1224 }
1225 }
1226 }
1227
1228 components.iter().collect()
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233 use super::*;
1234 use crate::workspace_index::CrossFileLinkIndex;
1235 use std::fs::File;
1236 use std::io::Write;
1237 use tempfile::tempdir;
1238
1239 #[test]
1240 fn test_strip_query_and_fragment() {
1241 assert_eq!(
1243 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1244 "file.png"
1245 );
1246 assert_eq!(
1247 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1248 "file.png"
1249 );
1250 assert_eq!(
1251 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1252 "file.png"
1253 );
1254
1255 assert_eq!(
1257 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1258 "file.md"
1259 );
1260 assert_eq!(
1261 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1262 "file.md"
1263 );
1264
1265 assert_eq!(
1267 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1268 "file.md"
1269 );
1270
1271 assert_eq!(
1273 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1274 "file.png"
1275 );
1276
1277 assert_eq!(
1279 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1280 "path/to/image.png"
1281 );
1282 assert_eq!(
1283 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1284 "path/to/image.png"
1285 );
1286
1287 assert_eq!(
1289 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1290 "file.md"
1291 );
1292 }
1293
1294 #[test]
1295 fn test_url_decode() {
1296 assert_eq!(
1298 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1299 "penguin with space.jpg"
1300 );
1301
1302 assert_eq!(
1304 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1305 "assets/my file name.png"
1306 );
1307
1308 assert_eq!(
1310 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1311 "hello world!.md"
1312 );
1313
1314 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1316
1317 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1319
1320 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1322
1323 assert_eq!(
1325 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1326 "normal-file.md"
1327 );
1328
1329 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1331
1332 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1334
1335 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1337
1338 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1340
1341 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1343
1344 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1346
1347 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1349
1350 assert_eq!(
1352 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1353 "path/to/file.md"
1354 );
1355
1356 assert_eq!(
1358 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1359 "hello world/foo bar.md"
1360 );
1361
1362 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1364
1365 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1367 }
1368
1369 #[test]
1370 fn test_url_encoded_filenames() {
1371 let temp_dir = tempdir().unwrap();
1373 let base_path = temp_dir.path();
1374
1375 let file_with_spaces = base_path.join("penguin with space.jpg");
1377 File::create(&file_with_spaces)
1378 .unwrap()
1379 .write_all(b"image data")
1380 .unwrap();
1381
1382 let subdir = base_path.join("my images");
1384 std::fs::create_dir(&subdir).unwrap();
1385 let nested_file = subdir.join("photo 1.png");
1386 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1387
1388 let content = r#"
1390# Test Document with URL-Encoded Links
1391
1392
1393
1394
1395"#;
1396
1397 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1398
1399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1400 let result = rule.check(&ctx).unwrap();
1401
1402 assert_eq!(
1404 result.len(),
1405 1,
1406 "Should only warn about missing%20file.jpg. Got: {result:?}"
1407 );
1408 assert!(
1409 result[0].message.contains("missing%20file.jpg"),
1410 "Warning should mention the URL-encoded filename"
1411 );
1412 }
1413
1414 #[test]
1415 fn test_external_urls() {
1416 let rule = MD057ExistingRelativeLinks::new();
1417
1418 assert!(rule.is_external_url("https://example.com"));
1420 assert!(rule.is_external_url("http://example.com"));
1421 assert!(rule.is_external_url("ftp://example.com"));
1422 assert!(rule.is_external_url("www.example.com"));
1423 assert!(rule.is_external_url("example.com"));
1424
1425 assert!(rule.is_external_url("file:///path/to/file"));
1427 assert!(rule.is_external_url("smb://server/share"));
1428 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1429 assert!(rule.is_external_url("mailto:user@example.com"));
1430 assert!(rule.is_external_url("tel:+1234567890"));
1431 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1432 assert!(rule.is_external_url("javascript:void(0)"));
1433 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1434 assert!(rule.is_external_url("git://github.com/repo.git"));
1435
1436 assert!(rule.is_external_url("user@example.com"));
1439 assert!(rule.is_external_url("steering@kubernetes.io"));
1440 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1441 assert!(rule.is_external_url("user_name@sub.domain.com"));
1442 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1443
1444 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"));
1455 assert!(!rule.is_external_url("/blog/2024/release.html"));
1456 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1457 assert!(!rule.is_external_url("/pkg/runtime"));
1458 assert!(!rule.is_external_url("/doc/go1compat"));
1459 assert!(!rule.is_external_url("/index.html"));
1460 assert!(!rule.is_external_url("/assets/logo.png"));
1461
1462 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1464 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1465 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1466 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1467 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1468
1469 assert!(rule.is_external_url("~/assets/image.png"));
1472 assert!(rule.is_external_url("~/components/Button.vue"));
1473 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1477 assert!(rule.is_external_url("@images/photo.jpg"));
1478 assert!(rule.is_external_url("@assets/styles.css"));
1479
1480 assert!(!rule.is_external_url("./relative/path.md"));
1482 assert!(!rule.is_external_url("relative/path.md"));
1483 assert!(!rule.is_external_url("../parent/path.md"));
1484 }
1485
1486 #[test]
1487 fn test_framework_path_aliases() {
1488 let temp_dir = tempdir().unwrap();
1490 let base_path = temp_dir.path();
1491
1492 let content = r#"
1494# Framework Path Aliases
1495
1496
1497
1498
1499
1500[Link](@/pages/about.md)
1501
1502This is a [real missing link](missing.md) that should be flagged.
1503"#;
1504
1505 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1506
1507 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1508 let result = rule.check(&ctx).unwrap();
1509
1510 assert_eq!(
1512 result.len(),
1513 1,
1514 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1515 );
1516 assert!(
1517 result[0].message.contains("missing.md"),
1518 "Warning should be for missing.md"
1519 );
1520 }
1521
1522 #[test]
1523 fn test_url_decode_security_path_traversal() {
1524 let temp_dir = tempdir().unwrap();
1527 let base_path = temp_dir.path();
1528
1529 let file_in_base = base_path.join("safe.md");
1531 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1532
1533 let content = r#"
1538[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1539[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1540[Safe link](safe.md)
1541"#;
1542
1543 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1544
1545 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1546 let result = rule.check(&ctx).unwrap();
1547
1548 assert_eq!(
1551 result.len(),
1552 2,
1553 "Should have warnings for traversal attempts. Got: {result:?}"
1554 );
1555 }
1556
1557 #[test]
1558 fn test_url_encoded_utf8_filenames() {
1559 let temp_dir = tempdir().unwrap();
1561 let base_path = temp_dir.path();
1562
1563 let cafe_file = base_path.join("café.md");
1565 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1566
1567 let content = r#"
1568[Café link](caf%C3%A9.md)
1569[Missing unicode](r%C3%A9sum%C3%A9.md)
1570"#;
1571
1572 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1573
1574 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1575 let result = rule.check(&ctx).unwrap();
1576
1577 assert_eq!(
1579 result.len(),
1580 1,
1581 "Should only warn about missing résumé.md. Got: {result:?}"
1582 );
1583 assert!(
1584 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1585 "Warning should mention the URL-encoded filename"
1586 );
1587 }
1588
1589 #[test]
1590 fn test_url_encoded_emoji_filenames() {
1591 let temp_dir = tempdir().unwrap();
1594 let base_path = temp_dir.path();
1595
1596 let emoji_dir = base_path.join("👤 Personal");
1598 std::fs::create_dir(&emoji_dir).unwrap();
1599
1600 let file_path = emoji_dir.join("TV Shows.md");
1602 File::create(&file_path)
1603 .unwrap()
1604 .write_all(b"# TV Shows\n\nContent here.")
1605 .unwrap();
1606
1607 let content = r#"
1610# Test Document
1611
1612[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1613[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1614"#;
1615
1616 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1617
1618 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1619 let result = rule.check(&ctx).unwrap();
1620
1621 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1623 assert!(
1624 result[0].message.contains("Missing.md"),
1625 "Warning should be for Missing.md, got: {}",
1626 result[0].message
1627 );
1628 }
1629
1630 #[test]
1631 fn test_no_warnings_without_base_path() {
1632 let rule = MD057ExistingRelativeLinks::new();
1633 let content = "[Link](missing.md)";
1634
1635 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1636 let result = rule.check(&ctx).unwrap();
1637 assert!(result.is_empty(), "Should have no warnings without base path");
1638 }
1639
1640 #[test]
1641 fn test_existing_and_missing_links() {
1642 let temp_dir = tempdir().unwrap();
1644 let base_path = temp_dir.path();
1645
1646 let exists_path = base_path.join("exists.md");
1648 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1649
1650 assert!(exists_path.exists(), "exists.md should exist for this test");
1652
1653 let content = r#"
1655# Test Document
1656
1657[Valid Link](exists.md)
1658[Invalid Link](missing.md)
1659[External Link](https://example.com)
1660[Media Link](image.jpg)
1661 "#;
1662
1663 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1665
1666 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668 let result = rule.check(&ctx).unwrap();
1669
1670 assert_eq!(result.len(), 2);
1672 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1673 assert!(messages.iter().any(|m| m.contains("missing.md")));
1674 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1675 }
1676
1677 #[test]
1678 fn test_angle_bracket_links() {
1679 let temp_dir = tempdir().unwrap();
1681 let base_path = temp_dir.path();
1682
1683 let exists_path = base_path.join("exists.md");
1685 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1686
1687 let content = r#"
1689# Test Document
1690
1691[Valid Link](<exists.md>)
1692[Invalid Link](<missing.md>)
1693[External Link](<https://example.com>)
1694 "#;
1695
1696 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1698
1699 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert_eq!(result.len(), 1, "Should have exactly one warning");
1704 assert!(
1705 result[0].message.contains("missing.md"),
1706 "Warning should mention missing.md"
1707 );
1708 }
1709
1710 #[test]
1711 fn test_angle_bracket_links_with_parens() {
1712 let temp_dir = tempdir().unwrap();
1714 let base_path = temp_dir.path();
1715
1716 let app_dir = base_path.join("app");
1718 std::fs::create_dir(&app_dir).unwrap();
1719 let upload_dir = app_dir.join("(upload)");
1720 std::fs::create_dir(&upload_dir).unwrap();
1721 let page_file = upload_dir.join("page.tsx");
1722 File::create(&page_file)
1723 .unwrap()
1724 .write_all(b"export default function Page() {}")
1725 .unwrap();
1726
1727 let content = r#"
1729# Test Document with Paths Containing Parens
1730
1731[Upload Page](<app/(upload)/page.tsx>)
1732[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1733[Missing](<app/(missing)/file.md>)
1734"#;
1735
1736 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1737
1738 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1739 let result = rule.check(&ctx).unwrap();
1740
1741 assert_eq!(
1743 result.len(),
1744 1,
1745 "Should have exactly one warning for missing file. Got: {result:?}"
1746 );
1747 assert!(
1748 result[0].message.contains("app/(missing)/file.md"),
1749 "Warning should mention app/(missing)/file.md"
1750 );
1751 }
1752
1753 #[test]
1754 fn test_all_file_types_checked() {
1755 let temp_dir = tempdir().unwrap();
1757 let base_path = temp_dir.path();
1758
1759 let content = r#"
1761[Image Link](image.jpg)
1762[Video Link](video.mp4)
1763[Markdown Link](document.md)
1764[PDF Link](file.pdf)
1765"#;
1766
1767 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1768
1769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770 let result = rule.check(&ctx).unwrap();
1771
1772 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1774 }
1775
1776 #[test]
1777 fn test_code_span_detection() {
1778 let rule = MD057ExistingRelativeLinks::new();
1779
1780 let temp_dir = tempdir().unwrap();
1782 let base_path = temp_dir.path();
1783
1784 let rule = rule.with_path(base_path);
1785
1786 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1788
1789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790 let result = rule.check(&ctx).unwrap();
1791
1792 assert_eq!(result.len(), 1, "Should only flag the real link");
1794 assert!(result[0].message.contains("nonexistent.md"));
1795 }
1796
1797 #[test]
1798 fn test_inline_code_spans() {
1799 let temp_dir = tempdir().unwrap();
1801 let base_path = temp_dir.path();
1802
1803 let content = r#"
1805# Test Document
1806
1807This is a normal link: [Link](missing.md)
1808
1809This is a code span with a link: `[Link](another-missing.md)`
1810
1811Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1812
1813 "#;
1814
1815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1817
1818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1820 let result = rule.check(&ctx).unwrap();
1821
1822 assert_eq!(result.len(), 1, "Should have exactly one warning");
1824 assert!(
1825 result[0].message.contains("missing.md"),
1826 "Warning should be for missing.md"
1827 );
1828 assert!(
1829 !result.iter().any(|w| w.message.contains("another-missing.md")),
1830 "Should not warn about link in code span"
1831 );
1832 assert!(
1833 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1834 "Should not warn about link in inline code"
1835 );
1836 }
1837
1838 #[test]
1839 fn test_extensionless_link_resolution() {
1840 let temp_dir = tempdir().unwrap();
1842 let base_path = temp_dir.path();
1843
1844 let page_path = base_path.join("page.md");
1846 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1847
1848 let content = r#"
1850# Test Document
1851
1852[Link without extension](page)
1853[Link with extension](page.md)
1854[Missing link](nonexistent)
1855"#;
1856
1857 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1858
1859 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1860 let result = rule.check(&ctx).unwrap();
1861
1862 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1865 assert!(
1866 result[0].message.contains("nonexistent"),
1867 "Warning should be for 'nonexistent' not 'page'"
1868 );
1869 }
1870
1871 #[test]
1873 fn test_cross_file_scope() {
1874 let rule = MD057ExistingRelativeLinks::new();
1875 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1876 }
1877
1878 #[test]
1879 fn test_contribute_to_index_extracts_markdown_links() {
1880 let rule = MD057ExistingRelativeLinks::new();
1881 let content = r#"
1882# Document
1883
1884[Link to docs](./docs/guide.md)
1885[Link with fragment](./other.md#section)
1886[External link](https://example.com)
1887[Image link](image.png)
1888[Media file](video.mp4)
1889"#;
1890
1891 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1892 let mut index = FileIndex::new();
1893 rule.contribute_to_index(&ctx, &mut index);
1894
1895 assert_eq!(index.cross_file_links.len(), 2);
1897
1898 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
1900 assert_eq!(index.cross_file_links[0].fragment, "");
1901
1902 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
1904 assert_eq!(index.cross_file_links[1].fragment, "section");
1905 }
1906
1907 #[test]
1908 fn test_contribute_to_index_skips_external_and_anchors() {
1909 let rule = MD057ExistingRelativeLinks::new();
1910 let content = r#"
1911# Document
1912
1913[External](https://example.com)
1914[Another external](http://example.org)
1915[Fragment only](#section)
1916[FTP link](ftp://files.example.com)
1917[Mail link](mailto:test@example.com)
1918[WWW link](www.example.com)
1919"#;
1920
1921 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1922 let mut index = FileIndex::new();
1923 rule.contribute_to_index(&ctx, &mut index);
1924
1925 assert_eq!(index.cross_file_links.len(), 0);
1927 }
1928
1929 #[test]
1930 fn test_cross_file_check_valid_link() {
1931 use crate::workspace_index::WorkspaceIndex;
1932
1933 let rule = MD057ExistingRelativeLinks::new();
1934
1935 let mut workspace_index = WorkspaceIndex::new();
1937 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
1938
1939 let mut file_index = FileIndex::new();
1941 file_index.add_cross_file_link(CrossFileLinkIndex {
1942 target_path: "guide.md".to_string(),
1943 fragment: "".to_string(),
1944 line: 5,
1945 column: 1,
1946 });
1947
1948 let warnings = rule
1950 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1951 .unwrap();
1952
1953 assert!(warnings.is_empty());
1955 }
1956
1957 #[test]
1958 fn test_cross_file_check_missing_link() {
1959 use crate::workspace_index::WorkspaceIndex;
1960
1961 let rule = MD057ExistingRelativeLinks::new();
1962
1963 let workspace_index = WorkspaceIndex::new();
1965
1966 let mut file_index = FileIndex::new();
1968 file_index.add_cross_file_link(CrossFileLinkIndex {
1969 target_path: "missing.md".to_string(),
1970 fragment: "".to_string(),
1971 line: 5,
1972 column: 1,
1973 });
1974
1975 let warnings = rule
1977 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
1978 .unwrap();
1979
1980 assert_eq!(warnings.len(), 1);
1982 assert!(warnings[0].message.contains("missing.md"));
1983 assert!(warnings[0].message.contains("does not exist"));
1984 }
1985
1986 #[test]
1987 fn test_cross_file_check_parent_path() {
1988 use crate::workspace_index::WorkspaceIndex;
1989
1990 let rule = MD057ExistingRelativeLinks::new();
1991
1992 let mut workspace_index = WorkspaceIndex::new();
1994 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
1995
1996 let mut file_index = FileIndex::new();
1998 file_index.add_cross_file_link(CrossFileLinkIndex {
1999 target_path: "../readme.md".to_string(),
2000 fragment: "".to_string(),
2001 line: 5,
2002 column: 1,
2003 });
2004
2005 let warnings = rule
2007 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2008 .unwrap();
2009
2010 assert!(warnings.is_empty());
2012 }
2013
2014 #[test]
2015 fn test_cross_file_check_html_link_with_md_source() {
2016 use crate::workspace_index::WorkspaceIndex;
2019
2020 let rule = MD057ExistingRelativeLinks::new();
2021
2022 let mut workspace_index = WorkspaceIndex::new();
2024 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2025
2026 let mut file_index = FileIndex::new();
2028 file_index.add_cross_file_link(CrossFileLinkIndex {
2029 target_path: "guide.html".to_string(),
2030 fragment: "section".to_string(),
2031 line: 10,
2032 column: 5,
2033 });
2034
2035 let warnings = rule
2037 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2038 .unwrap();
2039
2040 assert!(
2042 warnings.is_empty(),
2043 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2044 );
2045 }
2046
2047 #[test]
2048 fn test_cross_file_check_html_link_without_source() {
2049 use crate::workspace_index::WorkspaceIndex;
2051
2052 let rule = MD057ExistingRelativeLinks::new();
2053
2054 let workspace_index = WorkspaceIndex::new();
2056
2057 let mut file_index = FileIndex::new();
2059 file_index.add_cross_file_link(CrossFileLinkIndex {
2060 target_path: "missing.html".to_string(),
2061 fragment: "".to_string(),
2062 line: 10,
2063 column: 5,
2064 });
2065
2066 let warnings = rule
2068 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2069 .unwrap();
2070
2071 assert_eq!(warnings.len(), 1, "Expected 1 warning for .html link without source");
2073 assert!(warnings[0].message.contains("missing.html"));
2074 }
2075
2076 #[test]
2077 fn test_normalize_path_function() {
2078 assert_eq!(
2080 normalize_path(Path::new("docs/guide.md")),
2081 PathBuf::from("docs/guide.md")
2082 );
2083
2084 assert_eq!(
2086 normalize_path(Path::new("./docs/guide.md")),
2087 PathBuf::from("docs/guide.md")
2088 );
2089
2090 assert_eq!(
2092 normalize_path(Path::new("docs/sub/../guide.md")),
2093 PathBuf::from("docs/guide.md")
2094 );
2095
2096 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2098 }
2099
2100 #[test]
2101 fn test_html_link_with_md_source() {
2102 let temp_dir = tempdir().unwrap();
2104 let base_path = temp_dir.path();
2105
2106 let md_file = base_path.join("guide.md");
2108 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2109
2110 let content = r#"
2111[Read the guide](guide.html)
2112[Also here](getting-started.html)
2113"#;
2114
2115 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2116 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2117 let result = rule.check(&ctx).unwrap();
2118
2119 assert_eq!(
2121 result.len(),
2122 1,
2123 "Should only warn about missing source. Got: {result:?}"
2124 );
2125 assert!(result[0].message.contains("getting-started.html"));
2126 }
2127
2128 #[test]
2129 fn test_htm_link_with_md_source() {
2130 let temp_dir = tempdir().unwrap();
2132 let base_path = temp_dir.path();
2133
2134 let md_file = base_path.join("page.md");
2135 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2136
2137 let content = "[Page](page.htm)";
2138
2139 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2140 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2141 let result = rule.check(&ctx).unwrap();
2142
2143 assert!(
2144 result.is_empty(),
2145 "Should not warn when .md source exists for .htm link"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_html_link_finds_various_markdown_extensions() {
2151 let temp_dir = tempdir().unwrap();
2153 let base_path = temp_dir.path();
2154
2155 File::create(base_path.join("doc.md")).unwrap();
2156 File::create(base_path.join("tutorial.mdx")).unwrap();
2157 File::create(base_path.join("guide.markdown")).unwrap();
2158
2159 let content = r#"
2160[Doc](doc.html)
2161[Tutorial](tutorial.html)
2162[Guide](guide.html)
2163"#;
2164
2165 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2166 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2167 let result = rule.check(&ctx).unwrap();
2168
2169 assert!(
2170 result.is_empty(),
2171 "Should find all markdown variants as source files. Got: {result:?}"
2172 );
2173 }
2174
2175 #[test]
2176 fn test_html_link_in_subdirectory() {
2177 let temp_dir = tempdir().unwrap();
2179 let base_path = temp_dir.path();
2180
2181 let docs_dir = base_path.join("docs");
2182 std::fs::create_dir(&docs_dir).unwrap();
2183 File::create(docs_dir.join("guide.md"))
2184 .unwrap()
2185 .write_all(b"# Guide")
2186 .unwrap();
2187
2188 let content = "[Guide](docs/guide.html)";
2189
2190 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2191 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2192 let result = rule.check(&ctx).unwrap();
2193
2194 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2195 }
2196
2197 #[test]
2198 fn test_absolute_path_skipped_in_check() {
2199 let temp_dir = tempdir().unwrap();
2202 let base_path = temp_dir.path();
2203
2204 let content = r#"
2205# Test Document
2206
2207[Go Runtime](/pkg/runtime)
2208[Go Runtime with Fragment](/pkg/runtime#section)
2209[API Docs](/api/v1/users)
2210[Blog Post](/blog/2024/release.html)
2211[React Hook](/react/hooks/use-state.html)
2212"#;
2213
2214 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2215 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2216 let result = rule.check(&ctx).unwrap();
2217
2218 assert!(
2220 result.is_empty(),
2221 "Absolute paths should be skipped. Got warnings: {result:?}"
2222 );
2223 }
2224
2225 #[test]
2226 fn test_absolute_path_skipped_in_cross_file_check() {
2227 use crate::workspace_index::WorkspaceIndex;
2229
2230 let rule = MD057ExistingRelativeLinks::new();
2231
2232 let workspace_index = WorkspaceIndex::new();
2234
2235 let mut file_index = FileIndex::new();
2237 file_index.add_cross_file_link(CrossFileLinkIndex {
2238 target_path: "/pkg/runtime.md".to_string(),
2239 fragment: "".to_string(),
2240 line: 5,
2241 column: 1,
2242 });
2243 file_index.add_cross_file_link(CrossFileLinkIndex {
2244 target_path: "/api/v1/users.md".to_string(),
2245 fragment: "section".to_string(),
2246 line: 10,
2247 column: 1,
2248 });
2249
2250 let warnings = rule
2252 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2253 .unwrap();
2254
2255 assert!(
2257 warnings.is_empty(),
2258 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2259 );
2260 }
2261
2262 #[test]
2263 fn test_protocol_relative_url_not_skipped() {
2264 let temp_dir = tempdir().unwrap();
2267 let base_path = temp_dir.path();
2268
2269 let content = r#"
2270# Test Document
2271
2272[External](//example.com/page)
2273[Another](//cdn.example.com/asset.js)
2274"#;
2275
2276 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2277 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2278 let result = rule.check(&ctx).unwrap();
2279
2280 assert!(
2282 result.is_empty(),
2283 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2284 );
2285 }
2286
2287 #[test]
2288 fn test_email_addresses_skipped() {
2289 let temp_dir = tempdir().unwrap();
2292 let base_path = temp_dir.path();
2293
2294 let content = r#"
2295# Test Document
2296
2297[Contact](user@example.com)
2298[Steering](steering@kubernetes.io)
2299[Support](john.doe+filter@company.co.uk)
2300[User](user_name@sub.domain.com)
2301"#;
2302
2303 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2304 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2305 let result = rule.check(&ctx).unwrap();
2306
2307 assert!(
2309 result.is_empty(),
2310 "Email addresses should be skipped. Got warnings: {result:?}"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_email_addresses_vs_file_paths() {
2316 let temp_dir = tempdir().unwrap();
2319 let base_path = temp_dir.path();
2320
2321 let content = r#"
2322# Test Document
2323
2324[Email](user@example.com) <!-- Should be skipped (email) -->
2325[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2326[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2327"#;
2328
2329 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2330 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let result = rule.check(&ctx).unwrap();
2332
2333 assert!(
2335 result.is_empty(),
2336 "All email addresses should be skipped. Got: {result:?}"
2337 );
2338 }
2339
2340 #[test]
2341 fn test_diagnostic_position_accuracy() {
2342 let temp_dir = tempdir().unwrap();
2344 let base_path = temp_dir.path();
2345
2346 let content = "prefix [text](missing.md) suffix";
2349 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2353 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2354 let result = rule.check(&ctx).unwrap();
2355
2356 assert_eq!(result.len(), 1, "Should have exactly one warning");
2357 assert_eq!(result[0].line, 1, "Should be on line 1");
2358 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2359 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2360 }
2361
2362 #[test]
2363 fn test_diagnostic_position_angle_brackets() {
2364 let temp_dir = tempdir().unwrap();
2366 let base_path = temp_dir.path();
2367
2368 let content = "[link](<missing.md>)";
2371 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2374 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2375 let result = rule.check(&ctx).unwrap();
2376
2377 assert_eq!(result.len(), 1, "Should have exactly one warning");
2378 assert_eq!(result[0].line, 1, "Should be on line 1");
2379 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2380 }
2381
2382 #[test]
2383 fn test_diagnostic_position_multiline() {
2384 let temp_dir = tempdir().unwrap();
2386 let base_path = temp_dir.path();
2387
2388 let content = r#"# Title
2389Some text on line 2
2390[link on line 3](missing1.md)
2391More text
2392[link on line 5](missing2.md)"#;
2393
2394 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2395 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2396 let result = rule.check(&ctx).unwrap();
2397
2398 assert_eq!(result.len(), 2, "Should have two warnings");
2399
2400 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2402 assert!(result[0].message.contains("missing1.md"));
2403
2404 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2406 assert!(result[1].message.contains("missing2.md"));
2407 }
2408
2409 #[test]
2410 fn test_diagnostic_position_with_spaces() {
2411 let temp_dir = tempdir().unwrap();
2413 let base_path = temp_dir.path();
2414
2415 let content = "[link]( missing.md )";
2416 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2421 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2422 let result = rule.check(&ctx).unwrap();
2423
2424 assert_eq!(result.len(), 1, "Should have exactly one warning");
2425 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2427 }
2428
2429 #[test]
2430 fn test_diagnostic_position_image() {
2431 let temp_dir = tempdir().unwrap();
2433 let base_path = temp_dir.path();
2434
2435 let content = "";
2436
2437 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2438 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2439 let result = rule.check(&ctx).unwrap();
2440
2441 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2442 assert_eq!(result[0].line, 1);
2443 assert!(result[0].column > 0, "Should have valid column position");
2445 assert!(result[0].message.contains("missing.jpg"));
2446 }
2447
2448 #[test]
2449 fn test_wikilinks_skipped() {
2450 let temp_dir = tempdir().unwrap();
2453 let base_path = temp_dir.path();
2454
2455 let content = r#"# Test Document
2456
2457[[Microsoft#Windows OS]]
2458[[SomePage]]
2459[[Page With Spaces]]
2460[[path/to/page#section]]
2461[[page|Display Text]]
2462
2463This is a [real missing link](missing.md) that should be flagged.
2464"#;
2465
2466 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2467 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2468 let result = rule.check(&ctx).unwrap();
2469
2470 assert_eq!(
2472 result.len(),
2473 1,
2474 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2475 );
2476 assert!(
2477 result[0].message.contains("missing.md"),
2478 "Warning should be for missing.md, not wikilinks"
2479 );
2480 }
2481
2482 #[test]
2483 fn test_wikilinks_not_added_to_index() {
2484 let temp_dir = tempdir().unwrap();
2486 let base_path = temp_dir.path();
2487
2488 let content = r#"# Test Document
2489
2490[[Microsoft#Windows OS]]
2491[[SomePage#section]]
2492[Regular Link](other.md)
2493"#;
2494
2495 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2496 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2497
2498 let mut file_index = FileIndex::new();
2499 rule.contribute_to_index(&ctx, &mut file_index);
2500
2501 let cross_file_links = &file_index.cross_file_links;
2504 assert_eq!(
2505 cross_file_links.len(),
2506 1,
2507 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2508 );
2509 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2510 }
2511
2512 #[test]
2513 fn test_reference_definition_missing_file() {
2514 let temp_dir = tempdir().unwrap();
2516 let base_path = temp_dir.path();
2517
2518 let content = r#"# Test Document
2519
2520[test]: ./missing.md
2521[example]: ./nonexistent.html
2522
2523Use [test] and [example] here.
2524"#;
2525
2526 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2527 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2528 let result = rule.check(&ctx).unwrap();
2529
2530 assert_eq!(
2532 result.len(),
2533 2,
2534 "Should have warnings for missing reference definition targets. Got: {result:?}"
2535 );
2536 assert!(
2537 result.iter().any(|w| w.message.contains("missing.md")),
2538 "Should warn about missing.md"
2539 );
2540 assert!(
2541 result.iter().any(|w| w.message.contains("nonexistent.html")),
2542 "Should warn about nonexistent.html"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_reference_definition_existing_file() {
2548 let temp_dir = tempdir().unwrap();
2550 let base_path = temp_dir.path();
2551
2552 let exists_path = base_path.join("exists.md");
2554 File::create(&exists_path)
2555 .unwrap()
2556 .write_all(b"# Existing file")
2557 .unwrap();
2558
2559 let content = r#"# Test Document
2560
2561[test]: ./exists.md
2562
2563Use [test] here.
2564"#;
2565
2566 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2567 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2568 let result = rule.check(&ctx).unwrap();
2569
2570 assert!(
2572 result.is_empty(),
2573 "Should not warn about existing file. Got: {result:?}"
2574 );
2575 }
2576
2577 #[test]
2578 fn test_reference_definition_external_url_skipped() {
2579 let temp_dir = tempdir().unwrap();
2581 let base_path = temp_dir.path();
2582
2583 let content = r#"# Test Document
2584
2585[google]: https://google.com
2586[example]: http://example.org
2587[mail]: mailto:test@example.com
2588[ftp]: ftp://files.example.com
2589[local]: ./missing.md
2590
2591Use [google], [example], [mail], [ftp], [local] here.
2592"#;
2593
2594 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2595 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2596 let result = rule.check(&ctx).unwrap();
2597
2598 assert_eq!(
2600 result.len(),
2601 1,
2602 "Should only warn about local missing file. Got: {result:?}"
2603 );
2604 assert!(
2605 result[0].message.contains("missing.md"),
2606 "Warning should be for missing.md"
2607 );
2608 }
2609
2610 #[test]
2611 fn test_reference_definition_fragment_only_skipped() {
2612 let temp_dir = tempdir().unwrap();
2614 let base_path = temp_dir.path();
2615
2616 let content = r#"# Test Document
2617
2618[section]: #my-section
2619
2620Use [section] here.
2621"#;
2622
2623 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2624 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2625 let result = rule.check(&ctx).unwrap();
2626
2627 assert!(
2629 result.is_empty(),
2630 "Should not warn about fragment-only reference. Got: {result:?}"
2631 );
2632 }
2633
2634 #[test]
2635 fn test_reference_definition_column_position() {
2636 let temp_dir = tempdir().unwrap();
2638 let base_path = temp_dir.path();
2639
2640 let content = "[ref]: ./missing.md";
2643 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2647 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2648 let result = rule.check(&ctx).unwrap();
2649
2650 assert_eq!(result.len(), 1, "Should have exactly one warning");
2651 assert_eq!(result[0].line, 1, "Should be on line 1");
2652 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2653 }
2654
2655 #[test]
2656 fn test_reference_definition_html_with_md_source() {
2657 let temp_dir = tempdir().unwrap();
2659 let base_path = temp_dir.path();
2660
2661 let md_file = base_path.join("guide.md");
2663 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2664
2665 let content = r#"# Test Document
2666
2667[guide]: ./guide.html
2668[missing]: ./missing.html
2669
2670Use [guide] and [missing] here.
2671"#;
2672
2673 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2674 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2675 let result = rule.check(&ctx).unwrap();
2676
2677 assert_eq!(
2679 result.len(),
2680 1,
2681 "Should only warn about missing source. Got: {result:?}"
2682 );
2683 assert!(result[0].message.contains("missing.html"));
2684 }
2685
2686 #[test]
2687 fn test_reference_definition_url_encoded() {
2688 let temp_dir = tempdir().unwrap();
2690 let base_path = temp_dir.path();
2691
2692 let file_with_spaces = base_path.join("file with spaces.md");
2694 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2695
2696 let content = r#"# Test Document
2697
2698[spaces]: ./file%20with%20spaces.md
2699[missing]: ./missing%20file.md
2700
2701Use [spaces] and [missing] here.
2702"#;
2703
2704 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2705 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2706 let result = rule.check(&ctx).unwrap();
2707
2708 assert_eq!(
2710 result.len(),
2711 1,
2712 "Should only warn about missing URL-encoded file. Got: {result:?}"
2713 );
2714 assert!(result[0].message.contains("missing%20file.md"));
2715 }
2716
2717 #[test]
2718 fn test_inline_and_reference_both_checked() {
2719 let temp_dir = tempdir().unwrap();
2721 let base_path = temp_dir.path();
2722
2723 let content = r#"# Test Document
2724
2725[inline link](./inline-missing.md)
2726[ref]: ./ref-missing.md
2727
2728Use [ref] here.
2729"#;
2730
2731 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2732 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2733 let result = rule.check(&ctx).unwrap();
2734
2735 assert_eq!(
2737 result.len(),
2738 2,
2739 "Should warn about both inline and reference links. Got: {result:?}"
2740 );
2741 assert!(
2742 result.iter().any(|w| w.message.contains("inline-missing.md")),
2743 "Should warn about inline-missing.md"
2744 );
2745 assert!(
2746 result.iter().any(|w| w.message.contains("ref-missing.md")),
2747 "Should warn about ref-missing.md"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_footnote_definitions_not_flagged() {
2753 let rule = MD057ExistingRelativeLinks::default();
2756
2757 let content = r#"# Title
2758
2759A footnote[^1].
2760
2761[^1]: [link](https://www.google.com).
2762"#;
2763
2764 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2765 let result = rule.check(&ctx).unwrap();
2766
2767 assert!(
2768 result.is_empty(),
2769 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2770 );
2771 }
2772
2773 #[test]
2774 fn test_footnote_with_relative_link_inside() {
2775 let rule = MD057ExistingRelativeLinks::default();
2778
2779 let content = r#"# Title
2780
2781See the footnote[^1].
2782
2783[^1]: Check out [this file](./existing.md) for more info.
2784[^2]: Also see [missing](./does-not-exist.md).
2785"#;
2786
2787 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2788 let result = rule.check(&ctx).unwrap();
2789
2790 for warning in &result {
2795 assert!(
2796 !warning.message.contains("[this file]"),
2797 "Footnote content should not be treated as URL: {warning:?}"
2798 );
2799 assert!(
2800 !warning.message.contains("[missing]"),
2801 "Footnote content should not be treated as URL: {warning:?}"
2802 );
2803 }
2804 }
2805
2806 #[test]
2807 fn test_mixed_footnotes_and_reference_definitions() {
2808 let temp_dir = tempdir().unwrap();
2810 let base_path = temp_dir.path();
2811
2812 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2813
2814 let content = r#"# Title
2815
2816A footnote[^1] and a [ref link][myref].
2817
2818[^1]: This is a footnote with [link](https://example.com).
2819
2820[myref]: ./missing-file.md "This should be checked"
2821"#;
2822
2823 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2824 let result = rule.check(&ctx).unwrap();
2825
2826 assert_eq!(
2828 result.len(),
2829 1,
2830 "Should only warn about the regular reference definition. Got: {result:?}"
2831 );
2832 assert!(
2833 result[0].message.contains("missing-file.md"),
2834 "Should warn about missing-file.md in reference definition"
2835 );
2836 }
2837
2838 #[test]
2839 fn test_absolute_links_ignore_by_default() {
2840 let temp_dir = tempdir().unwrap();
2842 let base_path = temp_dir.path();
2843
2844 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2845
2846 let content = r#"# Links
2847
2848[API docs](/api/v1/users)
2849[Blog post](/blog/2024/release.html)
2850
2851
2852[ref]: /docs/reference.md
2853"#;
2854
2855 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2856 let result = rule.check(&ctx).unwrap();
2857
2858 assert!(
2860 result.is_empty(),
2861 "Absolute links should be ignored by default. Got: {result:?}"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_absolute_links_warn_config() {
2867 let temp_dir = tempdir().unwrap();
2869 let base_path = temp_dir.path();
2870
2871 let config = MD057Config {
2872 absolute_links: AbsoluteLinksOption::Warn,
2873 ..Default::default()
2874 };
2875 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2876
2877 let content = r#"# Links
2878
2879[API docs](/api/v1/users)
2880[Blog post](/blog/2024/release.html)
2881"#;
2882
2883 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2884 let result = rule.check(&ctx).unwrap();
2885
2886 assert_eq!(
2888 result.len(),
2889 2,
2890 "Should warn about both absolute links. Got: {result:?}"
2891 );
2892 assert!(
2893 result[0].message.contains("cannot be validated locally"),
2894 "Warning should explain why: {}",
2895 result[0].message
2896 );
2897 assert!(
2898 result[0].message.contains("/api/v1/users"),
2899 "Warning should include the link path"
2900 );
2901 }
2902
2903 #[test]
2904 fn test_absolute_links_warn_images() {
2905 let temp_dir = tempdir().unwrap();
2907 let base_path = temp_dir.path();
2908
2909 let config = MD057Config {
2910 absolute_links: AbsoluteLinksOption::Warn,
2911 ..Default::default()
2912 };
2913 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2914
2915 let content = r#"# Images
2916
2917
2918"#;
2919
2920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2921 let result = rule.check(&ctx).unwrap();
2922
2923 assert_eq!(
2924 result.len(),
2925 1,
2926 "Should warn about absolute image path. Got: {result:?}"
2927 );
2928 assert!(
2929 result[0].message.contains("/assets/logo.png"),
2930 "Warning should include the image path"
2931 );
2932 }
2933
2934 #[test]
2935 fn test_absolute_links_warn_reference_definitions() {
2936 let temp_dir = tempdir().unwrap();
2938 let base_path = temp_dir.path();
2939
2940 let config = MD057Config {
2941 absolute_links: AbsoluteLinksOption::Warn,
2942 ..Default::default()
2943 };
2944 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2945
2946 let content = r#"# Reference
2947
2948See the [docs][ref].
2949
2950[ref]: /docs/reference.md
2951"#;
2952
2953 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2954 let result = rule.check(&ctx).unwrap();
2955
2956 assert_eq!(
2957 result.len(),
2958 1,
2959 "Should warn about absolute reference definition. Got: {result:?}"
2960 );
2961 assert!(
2962 result[0].message.contains("/docs/reference.md"),
2963 "Warning should include the reference path"
2964 );
2965 }
2966
2967 #[test]
2968 fn test_search_paths_inline_link() {
2969 let temp_dir = tempdir().unwrap();
2970 let base_path = temp_dir.path();
2971
2972 let assets_dir = base_path.join("assets");
2974 std::fs::create_dir_all(&assets_dir).unwrap();
2975 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
2976
2977 let config = MD057Config {
2978 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
2979 ..Default::default()
2980 };
2981 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2982
2983 let content = "# Test\n\n[Photo](photo.png)\n";
2984 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2985 let result = rule.check(&ctx).unwrap();
2986
2987 assert!(
2988 result.is_empty(),
2989 "Should find photo.png via search-paths. Got: {result:?}"
2990 );
2991 }
2992
2993 #[test]
2994 fn test_search_paths_image() {
2995 let temp_dir = tempdir().unwrap();
2996 let base_path = temp_dir.path();
2997
2998 let assets_dir = base_path.join("attachments");
2999 std::fs::create_dir_all(&assets_dir).unwrap();
3000 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3001
3002 let config = MD057Config {
3003 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3004 ..Default::default()
3005 };
3006 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3007
3008 let content = "# Test\n\n\n";
3009 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3010 let result = rule.check(&ctx).unwrap();
3011
3012 assert!(
3013 result.is_empty(),
3014 "Should find diagram.svg via search-paths. Got: {result:?}"
3015 );
3016 }
3017
3018 #[test]
3019 fn test_search_paths_reference_definition() {
3020 let temp_dir = tempdir().unwrap();
3021 let base_path = temp_dir.path();
3022
3023 let assets_dir = base_path.join("images");
3024 std::fs::create_dir_all(&assets_dir).unwrap();
3025 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3026
3027 let config = MD057Config {
3028 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3029 ..Default::default()
3030 };
3031 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3032
3033 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3034 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035 let result = rule.check(&ctx).unwrap();
3036
3037 assert!(
3038 result.is_empty(),
3039 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3040 );
3041 }
3042
3043 #[test]
3044 fn test_search_paths_still_warns_when_truly_missing() {
3045 let temp_dir = tempdir().unwrap();
3046 let base_path = temp_dir.path();
3047
3048 let assets_dir = base_path.join("assets");
3049 std::fs::create_dir_all(&assets_dir).unwrap();
3050
3051 let config = MD057Config {
3052 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3053 ..Default::default()
3054 };
3055 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3056
3057 let content = "# Test\n\n\n";
3058 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059 let result = rule.check(&ctx).unwrap();
3060
3061 assert_eq!(
3062 result.len(),
3063 1,
3064 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3065 );
3066 }
3067
3068 #[test]
3069 fn test_search_paths_nonexistent_directory() {
3070 let temp_dir = tempdir().unwrap();
3071 let base_path = temp_dir.path();
3072
3073 let config = MD057Config {
3074 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3075 ..Default::default()
3076 };
3077 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3078
3079 let content = "# Test\n\n\n";
3080 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3081 let result = rule.check(&ctx).unwrap();
3082
3083 assert_eq!(
3084 result.len(),
3085 1,
3086 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3087 );
3088 }
3089
3090 #[test]
3091 fn test_obsidian_attachment_folder_named() {
3092 let temp_dir = tempdir().unwrap();
3093 let vault = temp_dir.path().join("vault");
3094 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3095 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3096 std::fs::create_dir_all(vault.join("notes")).unwrap();
3097
3098 std::fs::write(
3099 vault.join(".obsidian/app.json"),
3100 r#"{"attachmentFolderPath": "Attachments"}"#,
3101 )
3102 .unwrap();
3103 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3104
3105 let notes_dir = vault.join("notes");
3106 let source_file = notes_dir.join("test.md");
3107 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3108
3109 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3110
3111 let content = "# Test\n\n\n";
3112 let ctx =
3113 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3114 let result = rule.check(&ctx).unwrap();
3115
3116 assert!(
3117 result.is_empty(),
3118 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3119 );
3120 }
3121
3122 #[test]
3123 fn test_obsidian_attachment_same_folder_as_file() {
3124 let temp_dir = tempdir().unwrap();
3125 let vault = temp_dir.path().join("vault-rf");
3126 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3127 std::fs::create_dir_all(vault.join("notes")).unwrap();
3128
3129 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3130
3131 let notes_dir = vault.join("notes");
3133 let source_file = notes_dir.join("test.md");
3134 std::fs::write(&source_file, "placeholder").unwrap();
3135 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3136
3137 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3138
3139 let content = "# Test\n\n\n";
3140 let ctx =
3141 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3142 let result = rule.check(&ctx).unwrap();
3143
3144 assert!(
3145 result.is_empty(),
3146 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3147 );
3148 }
3149
3150 #[test]
3151 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3152 let temp_dir = tempdir().unwrap();
3153 let vault = temp_dir.path().join("vault-nf");
3154 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3155 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3156 std::fs::create_dir_all(vault.join("notes")).unwrap();
3157
3158 std::fs::write(
3159 vault.join(".obsidian/app.json"),
3160 r#"{"attachmentFolderPath": "Attachments"}"#,
3161 )
3162 .unwrap();
3163 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3164
3165 let notes_dir = vault.join("notes");
3166 let source_file = notes_dir.join("test.md");
3167 std::fs::write(&source_file, "placeholder").unwrap();
3168
3169 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3170
3171 let content = "# Test\n\n\n";
3172 let ctx =
3174 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3175 let result = rule.check(&ctx).unwrap();
3176
3177 assert_eq!(
3178 result.len(),
3179 1,
3180 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3181 );
3182 }
3183
3184 #[test]
3185 fn test_search_paths_combined_with_obsidian() {
3186 let temp_dir = tempdir().unwrap();
3187 let vault = temp_dir.path().join("vault-combo");
3188 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3189 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3190 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3191 std::fs::create_dir_all(vault.join("notes")).unwrap();
3192
3193 std::fs::write(
3194 vault.join(".obsidian/app.json"),
3195 r#"{"attachmentFolderPath": "Attachments"}"#,
3196 )
3197 .unwrap();
3198 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3199 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3200
3201 let notes_dir = vault.join("notes");
3202 let source_file = notes_dir.join("test.md");
3203 std::fs::write(&source_file, "placeholder").unwrap();
3204
3205 let extra_assets_dir = vault.join("extra-assets");
3206 let config = MD057Config {
3207 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3208 ..Default::default()
3209 };
3210 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3211
3212 let content = "# Test\n\n\n\n\n";
3214 let ctx =
3215 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3216 let result = rule.check(&ctx).unwrap();
3217
3218 assert!(
3219 result.is_empty(),
3220 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3221 );
3222 }
3223
3224 #[test]
3225 fn test_obsidian_attachment_subfolder_under_file() {
3226 let temp_dir = tempdir().unwrap();
3227 let vault = temp_dir.path().join("vault-sub");
3228 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3229 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3230
3231 std::fs::write(
3232 vault.join(".obsidian/app.json"),
3233 r#"{"attachmentFolderPath": "./assets"}"#,
3234 )
3235 .unwrap();
3236 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3237
3238 let notes_dir = vault.join("notes");
3239 let source_file = notes_dir.join("test.md");
3240 std::fs::write(&source_file, "placeholder").unwrap();
3241
3242 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3243
3244 let content = "# Test\n\n\n";
3245 let ctx =
3246 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3247 let result = rule.check(&ctx).unwrap();
3248
3249 assert!(
3250 result.is_empty(),
3251 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3252 );
3253 }
3254
3255 #[test]
3256 fn test_obsidian_attachment_vault_root() {
3257 let temp_dir = tempdir().unwrap();
3258 let vault = temp_dir.path().join("vault-root");
3259 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3260 std::fs::create_dir_all(vault.join("notes")).unwrap();
3261
3262 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3264 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3265
3266 let notes_dir = vault.join("notes");
3267 let source_file = notes_dir.join("test.md");
3268 std::fs::write(&source_file, "placeholder").unwrap();
3269
3270 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3271
3272 let content = "# Test\n\n\n";
3273 let ctx =
3274 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3275 let result = rule.check(&ctx).unwrap();
3276
3277 assert!(
3278 result.is_empty(),
3279 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3280 );
3281 }
3282
3283 #[test]
3284 fn test_search_paths_multiple_directories() {
3285 let temp_dir = tempdir().unwrap();
3286 let base_path = temp_dir.path();
3287
3288 let dir_a = base_path.join("dir-a");
3289 let dir_b = base_path.join("dir-b");
3290 std::fs::create_dir_all(&dir_a).unwrap();
3291 std::fs::create_dir_all(&dir_b).unwrap();
3292 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3293 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3294
3295 let config = MD057Config {
3296 search_paths: vec![
3297 dir_a.to_string_lossy().into_owned(),
3298 dir_b.to_string_lossy().into_owned(),
3299 ],
3300 ..Default::default()
3301 };
3302 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3303
3304 let content = "# Test\n\n\n\n\n";
3305 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3306 let result = rule.check(&ctx).unwrap();
3307
3308 assert!(
3309 result.is_empty(),
3310 "Should find files across multiple search paths. Got: {result:?}"
3311 );
3312 }
3313
3314 #[test]
3315 fn test_cross_file_check_with_search_paths() {
3316 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3317
3318 let temp_dir = tempdir().unwrap();
3319 let base_path = temp_dir.path();
3320
3321 let docs_dir = base_path.join("docs");
3323 std::fs::create_dir_all(&docs_dir).unwrap();
3324 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3325
3326 let config = MD057Config {
3327 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3328 ..Default::default()
3329 };
3330 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3331
3332 let file_path = base_path.join("README.md");
3333 std::fs::write(&file_path, "# Readme\n").unwrap();
3334
3335 let mut file_index = FileIndex::default();
3336 file_index.cross_file_links.push(CrossFileLinkIndex {
3337 target_path: "guide.md".to_string(),
3338 fragment: String::new(),
3339 line: 3,
3340 column: 1,
3341 });
3342
3343 let workspace_index = WorkspaceIndex::new();
3344
3345 let result = rule
3346 .cross_file_check(&file_path, &file_index, &workspace_index)
3347 .unwrap();
3348
3349 assert!(
3350 result.is_empty(),
3351 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3352 );
3353 }
3354
3355 #[test]
3356 fn test_cross_file_check_with_obsidian_flavor() {
3357 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3358
3359 let temp_dir = tempdir().unwrap();
3360 let vault = temp_dir.path().join("vault-xf");
3361 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3362 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3363 std::fs::create_dir_all(vault.join("notes")).unwrap();
3364
3365 std::fs::write(
3366 vault.join(".obsidian/app.json"),
3367 r#"{"attachmentFolderPath": "Attachments"}"#,
3368 )
3369 .unwrap();
3370 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3371
3372 let notes_dir = vault.join("notes");
3373 let file_path = notes_dir.join("test.md");
3374 std::fs::write(&file_path, "placeholder").unwrap();
3375
3376 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3377 .with_path(¬es_dir)
3378 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3379
3380 let mut file_index = FileIndex::default();
3381 file_index.cross_file_links.push(CrossFileLinkIndex {
3382 target_path: "ref.md".to_string(),
3383 fragment: String::new(),
3384 line: 3,
3385 column: 1,
3386 });
3387
3388 let workspace_index = WorkspaceIndex::new();
3389
3390 let result = rule
3391 .cross_file_check(&file_path, &file_index, &workspace_index)
3392 .unwrap();
3393
3394 assert!(
3395 result.is_empty(),
3396 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3397 );
3398 }
3399}