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