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 resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
174 if Path::new(path_str).is_absolute() {
175 PathBuf::from(path_str)
176 } else {
177 project_root.join(path_str)
178 }
179 }
180
181 #[cfg(test)]
183 fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
184 self.flavor = flavor;
185 self
186 }
187
188 #[inline]
200 fn is_external_url(&self, url: &str) -> bool {
201 if url.is_empty() {
202 return false;
203 }
204
205 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
207 return true;
208 }
209
210 if url.starts_with("{{") || url.starts_with("{%") {
213 return true;
214 }
215
216 if url.contains('@') {
219 return true; }
221
222 if url.ends_with(".com") {
229 return true;
230 }
231
232 if url.starts_with('~') || url.starts_with('@') {
236 return true;
237 }
238
239 false
241 }
242
243 #[inline]
245 fn is_fragment_only_link(&self, url: &str) -> bool {
246 url.starts_with('#')
247 }
248
249 #[inline]
252 fn is_absolute_path(url: &str) -> bool {
253 url.starts_with('/')
254 }
255
256 fn url_decode(path: &str) -> String {
260 if !path.contains('%') {
262 return path.to_string();
263 }
264
265 let bytes = path.as_bytes();
266 let mut result = Vec::with_capacity(bytes.len());
267 let mut i = 0;
268
269 while i < bytes.len() {
270 if bytes[i] == b'%' && i + 2 < bytes.len() {
271 let hex1 = bytes[i + 1];
273 let hex2 = bytes[i + 2];
274 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
275 result.push(d1 * 16 + d2);
276 i += 3;
277 continue;
278 }
279 }
280 result.push(bytes[i]);
281 i += 1;
282 }
283
284 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
286 }
287
288 fn strip_query_and_fragment(url: &str) -> &str {
296 let query_pos = url.find('?');
299 let fragment_pos = url.find('#');
300
301 match (query_pos, fragment_pos) {
302 (Some(q), Some(f)) => {
303 &url[..q.min(f)]
305 }
306 (Some(q), None) => &url[..q],
307 (None, Some(f)) => &url[..f],
308 (None, None) => url,
309 }
310 }
311
312 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
314 base_path.join(link)
315 }
316
317 fn compute_search_paths(
322 &self,
323 flavor: crate::config::MarkdownFlavor,
324 source_file: Option<&Path>,
325 base_path: &Path,
326 project_root: &Path,
327 ) -> Vec<PathBuf> {
328 let mut paths = Vec::new();
329
330 if flavor == crate::config::MarkdownFlavor::Obsidian
332 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
333 && attachment_dir != *base_path
334 {
335 paths.push(attachment_dir);
336 }
337
338 for search_path in &self.config.search_paths {
342 let resolved = Self::resolve_against_project_root(search_path, project_root);
343 if resolved != *base_path && !paths.contains(&resolved) {
344 paths.push(resolved);
345 }
346 }
347
348 paths
349 }
350
351 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
353 search_paths.iter().any(|dir| {
354 let candidate = dir.join(decoded_path);
355 file_exists_or_markdown_extension(&candidate)
356 })
357 }
358
359 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
365 if !self.config.compact_paths {
366 return None;
367 }
368
369 let path_end = url
371 .find('?')
372 .unwrap_or(url.len())
373 .min(url.find('#').unwrap_or(url.len()));
374 let path_part = &url[..path_end];
375 let suffix = &url[path_end..];
376
377 let decoded_path = Self::url_decode(path_part);
379
380 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
381 }
382
383 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
389 let Some(docs_dir) = resolve_docs_dir(source_path) else {
390 return Some(format!(
391 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
392 ));
393 };
394
395 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
396
397 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
400 Resolution::Found => None,
401 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
402 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
403 resolved.display()
404 )),
405 Resolution::NotFound { resolved } => Some(format!(
406 "Absolute link '{url}' resolves to '{}' which does not exist",
407 resolved.display()
408 )),
409 }
410 }
411
412 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
421 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
422
423 for root in roots {
424 let root_path = Self::resolve_against_project_root(root, project_root);
425 if matches!(
428 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
429 Resolution::Found
430 ) {
431 return None;
432 }
433 }
434
435 if matches!(
436 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
438 Resolution::Found
439 ) {
440 return None;
441 }
442
443 let msg = if roots.is_empty() {
444 format!("Absolute link '{url}' was not found under the project root")
445 } else {
446 format!("Absolute link '{url}' was not found under any configured root or the project root")
447 };
448 Some(msg)
449 }
450
451 fn prepare_absolute_url(url: &str) -> (String, bool) {
455 let relative_url = url.trim_start_matches('/');
456 let file_path = Self::strip_query_and_fragment(relative_url);
457 let decoded = Self::url_decode(file_path);
458 let is_directory_link = url.ends_with('/') || decoded.is_empty();
459 (decoded, is_directory_link)
460 }
461
462 fn resolve_under_root_with_opts(
484 root_path: &Path,
485 decoded: &str,
486 is_directory_link: bool,
487 require_index_for_dirs: bool,
488 ) -> Resolution {
489 let resolved = root_path.join(decoded);
490
491 let is_dir = resolved.is_dir();
492
493 if is_directory_link || (require_index_for_dirs && is_dir) {
498 let index_path = resolved.join("index.md");
499 if file_exists_with_cache(&index_path) {
500 return Resolution::Found;
501 }
502 if is_dir {
503 return Resolution::DirectoryWithoutIndex { resolved };
504 }
505 }
506
507 let decoded_has_trailing_slash = decoded.ends_with('/');
513 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
514 return Resolution::Found;
515 }
516
517 if file_exists_or_markdown_extension(&resolved) {
518 return Resolution::Found;
519 }
520
521 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
524 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
525 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
526 {
527 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
528 let source_path = parent.join(format!("{stem}{md_ext}"));
529 file_exists_with_cache(&source_path)
530 });
531 if has_md_source {
532 return Resolution::Found;
533 }
534 }
535
536 Resolution::NotFound { resolved }
537 }
538}
539
540enum Resolution {
544 Found,
545 DirectoryWithoutIndex { resolved: PathBuf },
546 NotFound { resolved: PathBuf },
547}
548
549impl Rule for MD057ExistingRelativeLinks {
550 fn name(&self) -> &'static str {
551 "MD057"
552 }
553
554 fn description(&self) -> &'static str {
555 "Relative links should point to existing files"
556 }
557
558 fn category(&self) -> RuleCategory {
559 RuleCategory::Link
560 }
561
562 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
563 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
564 }
565
566 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
567 let content = ctx.content;
568
569 if content.is_empty() || !content.contains('[') {
571 return Ok(Vec::new());
572 }
573
574 if !content.contains("](") && !content.contains("]:") {
577 return Ok(Vec::new());
578 }
579
580 reset_file_existence_cache();
582
583 let mut warnings = Vec::new();
584
585 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
589
590 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
594
595 let base_path: Option<PathBuf> = {
599 if explicit_base.is_some() {
600 explicit_base
601 } else if let Some(ref source_file) = ctx.source_file {
602 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
606 resolved_file
607 .parent()
608 .map(std::path::Path::to_path_buf)
609 .or_else(|| Some(CURRENT_DIR.clone()))
610 } else {
611 None
613 }
614 };
615
616 let Some(base_path) = base_path else {
618 return Ok(warnings);
619 };
620
621 let extra_search_paths =
623 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
624
625 if !ctx.links.is_empty() {
627 let line_index = &ctx.line_index;
629
630 let lines = ctx.raw_lines();
632
633 let mut processed_lines = std::collections::HashSet::new();
636
637 for link in &ctx.links {
638 let line_idx = link.line - 1;
639 if line_idx >= lines.len() {
640 continue;
641 }
642
643 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
645 continue;
646 }
647
648 if !processed_lines.insert(line_idx) {
650 continue;
651 }
652
653 let line = lines[line_idx];
654
655 if !line.contains("](") {
657 continue;
658 }
659
660 for link_match in LINK_START_REGEX.find_iter(line) {
662 let start_pos = link_match.start();
663 let end_pos = link_match.end();
664
665 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
667 let absolute_start_pos = line_start_byte + start_pos;
668
669 if ctx.is_in_code_span_byte(absolute_start_pos) {
671 continue;
672 }
673
674 if ctx.is_in_math_span(absolute_start_pos) {
676 continue;
677 }
678
679 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
683 .captures_at(line, end_pos - 1)
684 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
685 .or_else(|| {
686 URL_EXTRACT_REGEX
687 .captures_at(line, end_pos - 1)
688 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
689 });
690
691 if let Some((caps, url_group)) = caps_and_url {
692 let url = url_group.as_str().trim();
693
694 if url.is_empty() {
696 continue;
697 }
698
699 if url.starts_with('`') && url.ends_with('`') {
703 continue;
704 }
705
706 if self.is_external_url(url) || self.is_fragment_only_link(url) {
708 continue;
709 }
710
711 if Self::is_absolute_path(url) {
713 match self.config.absolute_links {
714 AbsoluteLinksOption::Warn => {
715 let url_start = url_group.start();
716 let url_end = url_group.end();
717 warnings.push(LintWarning {
718 rule_name: Some(self.name().to_string()),
719 line: link.line,
720 column: url_start + 1,
721 end_line: link.line,
722 end_column: url_end + 1,
723 message: format!("Absolute link '{url}' cannot be validated locally"),
724 severity: Severity::Warning,
725 fix: None,
726 });
727 }
728 AbsoluteLinksOption::RelativeToDocs => {
729 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
730 let url_start = url_group.start();
731 let url_end = url_group.end();
732 warnings.push(LintWarning {
733 rule_name: Some(self.name().to_string()),
734 line: link.line,
735 column: url_start + 1,
736 end_line: link.line,
737 end_column: url_end + 1,
738 message: msg,
739 severity: Severity::Warning,
740 fix: None,
741 });
742 }
743 }
744 AbsoluteLinksOption::RelativeToRoots => {
745 if let Some(msg) =
746 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
747 {
748 let url_start = url_group.start();
749 let url_end = url_group.end();
750 warnings.push(LintWarning {
751 rule_name: Some(self.name().to_string()),
752 line: link.line,
753 column: url_start + 1,
754 end_line: link.line,
755 end_column: url_end + 1,
756 message: msg,
757 severity: Severity::Warning,
758 fix: None,
759 });
760 }
761 }
762 AbsoluteLinksOption::Ignore => {}
763 }
764 continue;
765 }
766
767 let full_url_for_compact = if let Some(frag) = caps.get(2) {
771 format!("{url}{}", frag.as_str())
772 } else {
773 url.to_string()
774 };
775 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
776 let url_start = url_group.start();
777 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
778 let fix_byte_start = line_start_byte + url_start;
779 let fix_byte_end = line_start_byte + url_end;
780 warnings.push(LintWarning {
781 rule_name: Some(self.name().to_string()),
782 line: link.line,
783 column: url_start + 1,
784 end_line: link.line,
785 end_column: url_end + 1,
786 message: format!(
787 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
788 ),
789 severity: Severity::Warning,
790 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
791 });
792 }
793
794 let file_path = Self::strip_query_and_fragment(url);
796
797 let decoded_path = Self::url_decode(file_path);
799
800 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
802
803 if file_exists_or_markdown_extension(&resolved_path) {
805 continue; }
807
808 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
810 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
811 && let (Some(stem), Some(parent)) = (
812 resolved_path.file_stem().and_then(|s| s.to_str()),
813 resolved_path.parent(),
814 ) {
815 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
816 let source_path = parent.join(format!("{stem}{md_ext}"));
817 file_exists_with_cache(&source_path)
818 })
819 } else {
820 false
821 };
822
823 if has_md_source {
824 continue; }
826
827 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
829 continue;
830 }
831
832 let url_start = url_group.start();
836 let url_end = url_group.end();
837
838 warnings.push(LintWarning {
839 rule_name: Some(self.name().to_string()),
840 line: link.line,
841 column: url_start + 1, end_line: link.line,
843 end_column: url_end + 1, message: format!("Relative link '{url}' does not exist"),
845 severity: Severity::Error,
846 fix: None,
847 });
848 }
849 }
850 }
851 }
852
853 for image in &ctx.images {
855 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
857 continue;
858 }
859
860 let url = image.url.as_ref();
861
862 if url.is_empty() {
864 continue;
865 }
866
867 if self.is_external_url(url) || self.is_fragment_only_link(url) {
869 continue;
870 }
871
872 if Self::is_absolute_path(url) {
874 match self.config.absolute_links {
875 AbsoluteLinksOption::Warn => {
876 warnings.push(LintWarning {
877 rule_name: Some(self.name().to_string()),
878 line: image.line,
879 column: image.start_col + 1,
880 end_line: image.line,
881 end_column: image.start_col + 1 + url.len(),
882 message: format!("Absolute link '{url}' cannot be validated locally"),
883 severity: Severity::Warning,
884 fix: None,
885 });
886 }
887 AbsoluteLinksOption::RelativeToDocs => {
888 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
889 warnings.push(LintWarning {
890 rule_name: Some(self.name().to_string()),
891 line: image.line,
892 column: image.start_col + 1,
893 end_line: image.line,
894 end_column: image.start_col + 1 + url.len(),
895 message: msg,
896 severity: Severity::Warning,
897 fix: None,
898 });
899 }
900 }
901 AbsoluteLinksOption::RelativeToRoots => {
902 if let Some(msg) =
903 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
904 {
905 warnings.push(LintWarning {
906 rule_name: Some(self.name().to_string()),
907 line: image.line,
908 column: image.start_col + 1,
909 end_line: image.line,
910 end_column: image.start_col + 1 + url.len(),
911 message: msg,
912 severity: Severity::Warning,
913 fix: None,
914 });
915 }
916 }
917 AbsoluteLinksOption::Ignore => {}
918 }
919 continue;
920 }
921
922 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
924 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
927 let fix_byte_start = image.byte_offset + url_offset;
928 let fix_byte_end = fix_byte_start + url.len();
929 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
930 });
931
932 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
933 let url_col = fix
934 .as_ref()
935 .map_or(image.start_col + 1, |f| f.range.start - img_line_start_byte + 1);
936 warnings.push(LintWarning {
937 rule_name: Some(self.name().to_string()),
938 line: image.line,
939 column: url_col,
940 end_line: image.line,
941 end_column: url_col + url.len(),
942 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
943 severity: Severity::Warning,
944 fix,
945 });
946 }
947
948 let file_path = Self::strip_query_and_fragment(url);
950
951 let decoded_path = Self::url_decode(file_path);
953
954 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
956
957 if file_exists_or_markdown_extension(&resolved_path) {
959 continue; }
961
962 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
964 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
965 && let (Some(stem), Some(parent)) = (
966 resolved_path.file_stem().and_then(|s| s.to_str()),
967 resolved_path.parent(),
968 ) {
969 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
970 let source_path = parent.join(format!("{stem}{md_ext}"));
971 file_exists_with_cache(&source_path)
972 })
973 } else {
974 false
975 };
976
977 if has_md_source {
978 continue; }
980
981 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
983 continue;
984 }
985
986 warnings.push(LintWarning {
989 rule_name: Some(self.name().to_string()),
990 line: image.line,
991 column: image.start_col + 1,
992 end_line: image.line,
993 end_column: image.start_col + 1 + url.len(),
994 message: format!("Relative link '{url}' does not exist"),
995 severity: Severity::Error,
996 fix: None,
997 });
998 }
999
1000 for ref_def in &ctx.reference_defs {
1002 let url = &ref_def.url;
1003
1004 if url.is_empty() {
1006 continue;
1007 }
1008
1009 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1011 continue;
1012 }
1013
1014 if Self::is_absolute_path(url) {
1016 match self.config.absolute_links {
1017 AbsoluteLinksOption::Warn => {
1018 let line_idx = ref_def.line - 1;
1019 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1020 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1021 });
1022 warnings.push(LintWarning {
1023 rule_name: Some(self.name().to_string()),
1024 line: ref_def.line,
1025 column,
1026 end_line: ref_def.line,
1027 end_column: column + url.len(),
1028 message: format!("Absolute link '{url}' cannot be validated locally"),
1029 severity: Severity::Warning,
1030 fix: None,
1031 });
1032 }
1033 AbsoluteLinksOption::RelativeToDocs => {
1034 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
1035 let line_idx = ref_def.line - 1;
1036 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1037 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1038 });
1039 warnings.push(LintWarning {
1040 rule_name: Some(self.name().to_string()),
1041 line: ref_def.line,
1042 column,
1043 end_line: ref_def.line,
1044 end_column: column + url.len(),
1045 message: msg,
1046 severity: Severity::Warning,
1047 fix: None,
1048 });
1049 }
1050 }
1051 AbsoluteLinksOption::RelativeToRoots => {
1052 if let Some(msg) =
1053 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
1054 {
1055 let line_idx = ref_def.line - 1;
1056 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1057 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1058 });
1059 warnings.push(LintWarning {
1060 rule_name: Some(self.name().to_string()),
1061 line: ref_def.line,
1062 column,
1063 end_line: ref_def.line,
1064 end_column: column + url.len(),
1065 message: msg,
1066 severity: Severity::Warning,
1067 fix: None,
1068 });
1069 }
1070 }
1071 AbsoluteLinksOption::Ignore => {}
1072 }
1073 continue;
1074 }
1075
1076 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1078 let ref_line_idx = ref_def.line - 1;
1079 let col = content.lines().nth(ref_line_idx).map_or(1, |line_content| {
1080 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1081 });
1082 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1083 let fix_byte_start = ref_line_start_byte + col - 1;
1084 let fix_byte_end = fix_byte_start + url.len();
1085 warnings.push(LintWarning {
1086 rule_name: Some(self.name().to_string()),
1087 line: ref_def.line,
1088 column: col,
1089 end_line: ref_def.line,
1090 end_column: col + url.len(),
1091 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1092 severity: Severity::Warning,
1093 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1094 });
1095 }
1096
1097 let file_path = Self::strip_query_and_fragment(url);
1099
1100 let decoded_path = Self::url_decode(file_path);
1102
1103 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1105
1106 if file_exists_or_markdown_extension(&resolved_path) {
1108 continue; }
1110
1111 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1113 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1114 && let (Some(stem), Some(parent)) = (
1115 resolved_path.file_stem().and_then(|s| s.to_str()),
1116 resolved_path.parent(),
1117 ) {
1118 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1119 let source_path = parent.join(format!("{stem}{md_ext}"));
1120 file_exists_with_cache(&source_path)
1121 })
1122 } else {
1123 false
1124 };
1125
1126 if has_md_source {
1127 continue; }
1129
1130 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1132 continue;
1133 }
1134
1135 let line_idx = ref_def.line - 1;
1138 let column = content.lines().nth(line_idx).map_or(1, |line_content| {
1139 line_content.find(url.as_str()).map_or(1, |url_pos| url_pos + 1)
1141 });
1142
1143 warnings.push(LintWarning {
1144 rule_name: Some(self.name().to_string()),
1145 line: ref_def.line,
1146 column,
1147 end_line: ref_def.line,
1148 end_column: column + url.len(),
1149 message: format!("Relative link '{url}' does not exist"),
1150 severity: Severity::Error,
1151 fix: None,
1152 });
1153 }
1154
1155 Ok(warnings)
1156 }
1157
1158 fn fix_capability(&self) -> FixCapability {
1159 if self.config.compact_paths {
1160 FixCapability::ConditionallyFixable
1161 } else {
1162 FixCapability::Unfixable
1163 }
1164 }
1165
1166 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1167 if !self.config.compact_paths {
1168 return Ok(ctx.content.to_string());
1169 }
1170
1171 let warnings = self.check(ctx)?;
1172 let warnings =
1173 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1174 let mut content = ctx.content.to_string();
1175
1176 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1178 fixes.sort_by(|a, b| b.range.start.cmp(&a.range.start));
1179
1180 for fix in fixes {
1181 if fix.range.end <= content.len() {
1182 content.replace_range(fix.range.clone(), &fix.replacement);
1183 }
1184 }
1185
1186 Ok(content)
1187 }
1188
1189 fn as_any(&self) -> &dyn std::any::Any {
1190 self
1191 }
1192
1193 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1194 let default_config = MD057Config::default();
1195 let json_value = serde_json::to_value(&default_config).ok()?;
1196 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1197
1198 if let toml::Value::Table(table) = toml_value {
1199 if !table.is_empty() {
1200 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1201 } else {
1202 None
1203 }
1204 } else {
1205 None
1206 }
1207 }
1208
1209 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1210 where
1211 Self: Sized,
1212 {
1213 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1214 let mut rule = Self::from_config_struct(rule_config);
1215 rule.flavor = config.global.flavor;
1216 Box::new(rule)
1217 }
1218
1219 fn cross_file_scope(&self) -> CrossFileScope {
1220 CrossFileScope::Workspace
1221 }
1222
1223 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1224 for link in extract_cross_file_links(ctx) {
1227 index.add_cross_file_link(link);
1228 }
1229 }
1230
1231 fn cross_file_check(
1232 &self,
1233 _file_path: &Path,
1234 _file_index: &FileIndex,
1235 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1236 ) -> LintResult {
1237 Ok(Vec::new())
1247 }
1248}
1249
1250fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1255 let from_components: Vec<_> = from_dir.components().collect();
1256 let to_components: Vec<_> = to_path.components().collect();
1257
1258 let common_len = from_components
1260 .iter()
1261 .zip(to_components.iter())
1262 .take_while(|(a, b)| a == b)
1263 .count();
1264
1265 let mut result = PathBuf::new();
1266
1267 for _ in common_len..from_components.len() {
1269 result.push("..");
1270 }
1271
1272 for component in &to_components[common_len..] {
1274 result.push(component);
1275 }
1276
1277 result
1278}
1279
1280fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1286 let link_path = Path::new(raw_link_path);
1287
1288 let has_traversal = link_path
1290 .components()
1291 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1292
1293 if !has_traversal {
1294 return None;
1295 }
1296
1297 let combined = source_dir.join(link_path);
1299 let normalized_target = normalize_path(&combined);
1300
1301 let normalized_source = normalize_path(source_dir);
1303 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1304
1305 if shortest != link_path {
1307 let compact = shortest.to_string_lossy().to_string();
1308 if compact.is_empty() {
1310 return None;
1311 }
1312 Some(compact.replace('\\', "/"))
1314 } else {
1315 None
1316 }
1317}
1318
1319fn normalize_path(path: &Path) -> PathBuf {
1321 let mut components = Vec::new();
1322
1323 for component in path.components() {
1324 match component {
1325 std::path::Component::ParentDir => {
1326 if !components.is_empty() {
1328 components.pop();
1329 }
1330 }
1331 std::path::Component::CurDir => {
1332 }
1334 _ => {
1335 components.push(component);
1336 }
1337 }
1338 }
1339
1340 components.iter().collect()
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::*;
1346 use crate::workspace_index::CrossFileLinkIndex;
1347 use std::fs::File;
1348 use std::io::Write;
1349 use tempfile::tempdir;
1350
1351 #[test]
1352 fn test_strip_query_and_fragment() {
1353 assert_eq!(
1355 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1356 "file.png"
1357 );
1358 assert_eq!(
1359 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1360 "file.png"
1361 );
1362 assert_eq!(
1363 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1364 "file.png"
1365 );
1366
1367 assert_eq!(
1369 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1370 "file.md"
1371 );
1372 assert_eq!(
1373 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1374 "file.md"
1375 );
1376
1377 assert_eq!(
1379 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1380 "file.md"
1381 );
1382
1383 assert_eq!(
1385 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1386 "file.png"
1387 );
1388
1389 assert_eq!(
1391 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1392 "path/to/image.png"
1393 );
1394 assert_eq!(
1395 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1396 "path/to/image.png"
1397 );
1398
1399 assert_eq!(
1401 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1402 "file.md"
1403 );
1404 }
1405
1406 #[test]
1407 fn test_url_decode() {
1408 assert_eq!(
1410 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1411 "penguin with space.jpg"
1412 );
1413
1414 assert_eq!(
1416 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1417 "assets/my file name.png"
1418 );
1419
1420 assert_eq!(
1422 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1423 "hello world!.md"
1424 );
1425
1426 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1428
1429 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1431
1432 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1434
1435 assert_eq!(
1437 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1438 "normal-file.md"
1439 );
1440
1441 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1443
1444 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1446
1447 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1449
1450 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1452
1453 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1455
1456 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1458
1459 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1461
1462 assert_eq!(
1464 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1465 "path/to/file.md"
1466 );
1467
1468 assert_eq!(
1470 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1471 "hello world/foo bar.md"
1472 );
1473
1474 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1476
1477 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1479 }
1480
1481 #[test]
1482 fn test_url_encoded_filenames() {
1483 let temp_dir = tempdir().unwrap();
1485 let base_path = temp_dir.path();
1486
1487 let file_with_spaces = base_path.join("penguin with space.jpg");
1489 File::create(&file_with_spaces)
1490 .unwrap()
1491 .write_all(b"image data")
1492 .unwrap();
1493
1494 let subdir = base_path.join("my images");
1496 std::fs::create_dir(&subdir).unwrap();
1497 let nested_file = subdir.join("photo 1.png");
1498 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1499
1500 let content = r#"
1502# Test Document with URL-Encoded Links
1503
1504
1505
1506
1507"#;
1508
1509 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1510
1511 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512 let result = rule.check(&ctx).unwrap();
1513
1514 assert_eq!(
1516 result.len(),
1517 1,
1518 "Should only warn about missing%20file.jpg. Got: {result:?}"
1519 );
1520 assert!(
1521 result[0].message.contains("missing%20file.jpg"),
1522 "Warning should mention the URL-encoded filename"
1523 );
1524 }
1525
1526 #[test]
1527 fn test_external_urls() {
1528 let rule = MD057ExistingRelativeLinks::new();
1529
1530 assert!(rule.is_external_url("https://example.com"));
1532 assert!(rule.is_external_url("http://example.com"));
1533 assert!(rule.is_external_url("ftp://example.com"));
1534 assert!(rule.is_external_url("www.example.com"));
1535 assert!(rule.is_external_url("example.com"));
1536
1537 assert!(rule.is_external_url("file:///path/to/file"));
1539 assert!(rule.is_external_url("smb://server/share"));
1540 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1541 assert!(rule.is_external_url("mailto:user@example.com"));
1542 assert!(rule.is_external_url("tel:+1234567890"));
1543 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1544 assert!(rule.is_external_url("javascript:void(0)"));
1545 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1546 assert!(rule.is_external_url("git://github.com/repo.git"));
1547
1548 assert!(rule.is_external_url("user@example.com"));
1551 assert!(rule.is_external_url("steering@kubernetes.io"));
1552 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1553 assert!(rule.is_external_url("user_name@sub.domain.com"));
1554 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1555
1556 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"));
1567 assert!(!rule.is_external_url("/blog/2024/release.html"));
1568 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1569 assert!(!rule.is_external_url("/pkg/runtime"));
1570 assert!(!rule.is_external_url("/doc/go1compat"));
1571 assert!(!rule.is_external_url("/index.html"));
1572 assert!(!rule.is_external_url("/assets/logo.png"));
1573
1574 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1576 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1577 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1578 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1579 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1580
1581 assert!(rule.is_external_url("~/assets/image.png"));
1584 assert!(rule.is_external_url("~/components/Button.vue"));
1585 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1589 assert!(rule.is_external_url("@images/photo.jpg"));
1590 assert!(rule.is_external_url("@assets/styles.css"));
1591
1592 assert!(!rule.is_external_url("./relative/path.md"));
1594 assert!(!rule.is_external_url("relative/path.md"));
1595 assert!(!rule.is_external_url("../parent/path.md"));
1596 }
1597
1598 #[test]
1599 fn test_framework_path_aliases() {
1600 let temp_dir = tempdir().unwrap();
1602 let base_path = temp_dir.path();
1603
1604 let content = r#"
1606# Framework Path Aliases
1607
1608
1609
1610
1611
1612[Link](@/pages/about.md)
1613
1614This is a [real missing link](missing.md) that should be flagged.
1615"#;
1616
1617 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1618
1619 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1620 let result = rule.check(&ctx).unwrap();
1621
1622 assert_eq!(
1624 result.len(),
1625 1,
1626 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1627 );
1628 assert!(
1629 result[0].message.contains("missing.md"),
1630 "Warning should be for missing.md"
1631 );
1632 }
1633
1634 #[test]
1635 fn test_url_decode_security_path_traversal() {
1636 let temp_dir = tempdir().unwrap();
1639 let base_path = temp_dir.path();
1640
1641 let file_in_base = base_path.join("safe.md");
1643 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1644
1645 let content = r#"
1650[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1651[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1652[Safe link](safe.md)
1653"#;
1654
1655 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1656
1657 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1658 let result = rule.check(&ctx).unwrap();
1659
1660 assert_eq!(
1663 result.len(),
1664 2,
1665 "Should have warnings for traversal attempts. Got: {result:?}"
1666 );
1667 }
1668
1669 #[test]
1670 fn test_url_encoded_utf8_filenames() {
1671 let temp_dir = tempdir().unwrap();
1673 let base_path = temp_dir.path();
1674
1675 let cafe_file = base_path.join("café.md");
1677 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1678
1679 let content = r#"
1680[Café link](caf%C3%A9.md)
1681[Missing unicode](r%C3%A9sum%C3%A9.md)
1682"#;
1683
1684 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1685
1686 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1687 let result = rule.check(&ctx).unwrap();
1688
1689 assert_eq!(
1691 result.len(),
1692 1,
1693 "Should only warn about missing résumé.md. Got: {result:?}"
1694 );
1695 assert!(
1696 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1697 "Warning should mention the URL-encoded filename"
1698 );
1699 }
1700
1701 #[test]
1702 fn test_url_encoded_emoji_filenames() {
1703 let temp_dir = tempdir().unwrap();
1706 let base_path = temp_dir.path();
1707
1708 let emoji_dir = base_path.join("👤 Personal");
1710 std::fs::create_dir(&emoji_dir).unwrap();
1711
1712 let file_path = emoji_dir.join("TV Shows.md");
1714 File::create(&file_path)
1715 .unwrap()
1716 .write_all(b"# TV Shows\n\nContent here.")
1717 .unwrap();
1718
1719 let content = r#"
1722# Test Document
1723
1724[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1725[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1726"#;
1727
1728 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1729
1730 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1731 let result = rule.check(&ctx).unwrap();
1732
1733 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1735 assert!(
1736 result[0].message.contains("Missing.md"),
1737 "Warning should be for Missing.md, got: {}",
1738 result[0].message
1739 );
1740 }
1741
1742 #[test]
1743 fn test_no_warnings_without_base_path() {
1744 let rule = MD057ExistingRelativeLinks::new();
1745 let content = "[Link](missing.md)";
1746
1747 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let result = rule.check(&ctx).unwrap();
1749 assert!(result.is_empty(), "Should have no warnings without base path");
1750 }
1751
1752 #[test]
1753 fn test_existing_and_missing_links() {
1754 let temp_dir = tempdir().unwrap();
1756 let base_path = temp_dir.path();
1757
1758 let exists_path = base_path.join("exists.md");
1760 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1761
1762 assert!(exists_path.exists(), "exists.md should exist for this test");
1764
1765 let content = r#"
1767# Test Document
1768
1769[Valid Link](exists.md)
1770[Invalid Link](missing.md)
1771[External Link](https://example.com)
1772[Media Link](image.jpg)
1773 "#;
1774
1775 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1777
1778 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1780 let result = rule.check(&ctx).unwrap();
1781
1782 assert_eq!(result.len(), 2);
1784 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1785 assert!(messages.iter().any(|m| m.contains("missing.md")));
1786 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1787 }
1788
1789 #[test]
1790 fn test_angle_bracket_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 let content = r#"
1801# Test Document
1802
1803[Valid Link](<exists.md>)
1804[Invalid Link](<missing.md>)
1805[External Link](<https://example.com>)
1806 "#;
1807
1808 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1810
1811 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1812 let result = rule.check(&ctx).unwrap();
1813
1814 assert_eq!(result.len(), 1, "Should have exactly one warning");
1816 assert!(
1817 result[0].message.contains("missing.md"),
1818 "Warning should mention missing.md"
1819 );
1820 }
1821
1822 #[test]
1823 fn test_angle_bracket_links_with_parens() {
1824 let temp_dir = tempdir().unwrap();
1826 let base_path = temp_dir.path();
1827
1828 let app_dir = base_path.join("app");
1830 std::fs::create_dir(&app_dir).unwrap();
1831 let upload_dir = app_dir.join("(upload)");
1832 std::fs::create_dir(&upload_dir).unwrap();
1833 let page_file = upload_dir.join("page.tsx");
1834 File::create(&page_file)
1835 .unwrap()
1836 .write_all(b"export default function Page() {}")
1837 .unwrap();
1838
1839 let content = r#"
1841# Test Document with Paths Containing Parens
1842
1843[Upload Page](<app/(upload)/page.tsx>)
1844[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1845[Missing](<app/(missing)/file.md>)
1846"#;
1847
1848 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1849
1850 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert_eq!(
1855 result.len(),
1856 1,
1857 "Should have exactly one warning for missing file. Got: {result:?}"
1858 );
1859 assert!(
1860 result[0].message.contains("app/(missing)/file.md"),
1861 "Warning should mention app/(missing)/file.md"
1862 );
1863 }
1864
1865 #[test]
1866 fn test_all_file_types_checked() {
1867 let temp_dir = tempdir().unwrap();
1869 let base_path = temp_dir.path();
1870
1871 let content = r#"
1873[Image Link](image.jpg)
1874[Video Link](video.mp4)
1875[Markdown Link](document.md)
1876[PDF Link](file.pdf)
1877"#;
1878
1879 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1880
1881 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1882 let result = rule.check(&ctx).unwrap();
1883
1884 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1886 }
1887
1888 #[test]
1889 fn test_code_span_detection() {
1890 let rule = MD057ExistingRelativeLinks::new();
1891
1892 let temp_dir = tempdir().unwrap();
1894 let base_path = temp_dir.path();
1895
1896 let rule = rule.with_path(base_path);
1897
1898 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1900
1901 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902 let result = rule.check(&ctx).unwrap();
1903
1904 assert_eq!(result.len(), 1, "Should only flag the real link");
1906 assert!(result[0].message.contains("nonexistent.md"));
1907 }
1908
1909 #[test]
1910 fn test_inline_code_spans() {
1911 let temp_dir = tempdir().unwrap();
1913 let base_path = temp_dir.path();
1914
1915 let content = r#"
1917# Test Document
1918
1919This is a normal link: [Link](missing.md)
1920
1921This is a code span with a link: `[Link](another-missing.md)`
1922
1923Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1924
1925 "#;
1926
1927 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1929
1930 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1932 let result = rule.check(&ctx).unwrap();
1933
1934 assert_eq!(result.len(), 1, "Should have exactly one warning");
1936 assert!(
1937 result[0].message.contains("missing.md"),
1938 "Warning should be for missing.md"
1939 );
1940 assert!(
1941 !result.iter().any(|w| w.message.contains("another-missing.md")),
1942 "Should not warn about link in code span"
1943 );
1944 assert!(
1945 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1946 "Should not warn about link in inline code"
1947 );
1948 }
1949
1950 #[test]
1951 fn test_extensionless_link_resolution() {
1952 let temp_dir = tempdir().unwrap();
1954 let base_path = temp_dir.path();
1955
1956 let page_path = base_path.join("page.md");
1958 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1959
1960 let content = r#"
1962# Test Document
1963
1964[Link without extension](page)
1965[Link with extension](page.md)
1966[Missing link](nonexistent)
1967"#;
1968
1969 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1970
1971 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973
1974 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1977 assert!(
1978 result[0].message.contains("nonexistent"),
1979 "Warning should be for 'nonexistent' not 'page'"
1980 );
1981 }
1982
1983 #[test]
1985 fn test_cross_file_scope() {
1986 let rule = MD057ExistingRelativeLinks::new();
1987 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1988 }
1989
1990 #[test]
1991 fn test_contribute_to_index_extracts_markdown_links() {
1992 let rule = MD057ExistingRelativeLinks::new();
1993 let content = r#"
1994# Document
1995
1996[Link to docs](./docs/guide.md)
1997[Link with fragment](./other.md#section)
1998[External link](https://example.com)
1999[Image link](image.png)
2000[Media file](video.mp4)
2001"#;
2002
2003 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2004 let mut index = FileIndex::new();
2005 rule.contribute_to_index(&ctx, &mut index);
2006
2007 assert_eq!(index.cross_file_links.len(), 2);
2009
2010 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2012 assert_eq!(index.cross_file_links[0].fragment, "");
2013
2014 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2016 assert_eq!(index.cross_file_links[1].fragment, "section");
2017 }
2018
2019 #[test]
2020 fn test_contribute_to_index_skips_external_and_anchors() {
2021 let rule = MD057ExistingRelativeLinks::new();
2022 let content = r#"
2023# Document
2024
2025[External](https://example.com)
2026[Another external](http://example.org)
2027[Fragment only](#section)
2028[FTP link](ftp://files.example.com)
2029[Mail link](mailto:test@example.com)
2030[WWW link](www.example.com)
2031"#;
2032
2033 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2034 let mut index = FileIndex::new();
2035 rule.contribute_to_index(&ctx, &mut index);
2036
2037 assert_eq!(index.cross_file_links.len(), 0);
2039 }
2040
2041 #[test]
2042 fn test_cross_file_check_valid_link() {
2043 use crate::workspace_index::WorkspaceIndex;
2044
2045 let rule = MD057ExistingRelativeLinks::new();
2046
2047 let mut workspace_index = WorkspaceIndex::new();
2049 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2050
2051 let mut file_index = FileIndex::new();
2053 file_index.add_cross_file_link(CrossFileLinkIndex {
2054 target_path: "guide.md".to_string(),
2055 fragment: "".to_string(),
2056 line: 5,
2057 column: 1,
2058 });
2059
2060 let warnings = rule
2062 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2063 .unwrap();
2064
2065 assert!(warnings.is_empty());
2067 }
2068
2069 #[test]
2070 fn test_cross_file_check_missing_link() {
2071 use crate::workspace_index::WorkspaceIndex;
2074
2075 let rule = MD057ExistingRelativeLinks::new();
2076 let workspace_index = WorkspaceIndex::new();
2077
2078 let mut file_index = FileIndex::new();
2079 file_index.add_cross_file_link(CrossFileLinkIndex {
2080 target_path: "missing.md".to_string(),
2081 fragment: "".to_string(),
2082 line: 5,
2083 column: 1,
2084 });
2085
2086 let warnings = rule
2087 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2088 .unwrap();
2089
2090 assert!(
2092 warnings.is_empty(),
2093 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2094 );
2095 }
2096
2097 #[test]
2098 fn test_cross_file_check_parent_path() {
2099 use crate::workspace_index::WorkspaceIndex;
2100
2101 let rule = MD057ExistingRelativeLinks::new();
2102
2103 let mut workspace_index = WorkspaceIndex::new();
2105 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2106
2107 let mut file_index = FileIndex::new();
2109 file_index.add_cross_file_link(CrossFileLinkIndex {
2110 target_path: "../readme.md".to_string(),
2111 fragment: "".to_string(),
2112 line: 5,
2113 column: 1,
2114 });
2115
2116 let warnings = rule
2118 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2119 .unwrap();
2120
2121 assert!(warnings.is_empty());
2123 }
2124
2125 #[test]
2126 fn test_cross_file_check_html_link_with_md_source() {
2127 use crate::workspace_index::WorkspaceIndex;
2130
2131 let rule = MD057ExistingRelativeLinks::new();
2132
2133 let mut workspace_index = WorkspaceIndex::new();
2135 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2136
2137 let mut file_index = FileIndex::new();
2139 file_index.add_cross_file_link(CrossFileLinkIndex {
2140 target_path: "guide.html".to_string(),
2141 fragment: "section".to_string(),
2142 line: 10,
2143 column: 5,
2144 });
2145
2146 let warnings = rule
2148 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2149 .unwrap();
2150
2151 assert!(
2153 warnings.is_empty(),
2154 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2155 );
2156 }
2157
2158 #[test]
2159 fn test_cross_file_check_html_link_without_source() {
2160 use crate::workspace_index::WorkspaceIndex;
2164
2165 let rule = MD057ExistingRelativeLinks::new();
2166 let workspace_index = WorkspaceIndex::new();
2167
2168 let mut file_index = FileIndex::new();
2169 file_index.add_cross_file_link(CrossFileLinkIndex {
2170 target_path: "missing.html".to_string(),
2171 fragment: "".to_string(),
2172 line: 10,
2173 column: 5,
2174 });
2175
2176 let warnings = rule
2177 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2178 .unwrap();
2179
2180 assert!(
2182 warnings.is_empty(),
2183 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_normalize_path_function() {
2189 assert_eq!(
2191 normalize_path(Path::new("docs/guide.md")),
2192 PathBuf::from("docs/guide.md")
2193 );
2194
2195 assert_eq!(
2197 normalize_path(Path::new("./docs/guide.md")),
2198 PathBuf::from("docs/guide.md")
2199 );
2200
2201 assert_eq!(
2203 normalize_path(Path::new("docs/sub/../guide.md")),
2204 PathBuf::from("docs/guide.md")
2205 );
2206
2207 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2209 }
2210
2211 #[test]
2212 fn test_html_link_with_md_source() {
2213 let temp_dir = tempdir().unwrap();
2215 let base_path = temp_dir.path();
2216
2217 let md_file = base_path.join("guide.md");
2219 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2220
2221 let content = r#"
2222[Read the guide](guide.html)
2223[Also here](getting-started.html)
2224"#;
2225
2226 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2227 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2228 let result = rule.check(&ctx).unwrap();
2229
2230 assert_eq!(
2232 result.len(),
2233 1,
2234 "Should only warn about missing source. Got: {result:?}"
2235 );
2236 assert!(result[0].message.contains("getting-started.html"));
2237 }
2238
2239 #[test]
2240 fn test_htm_link_with_md_source() {
2241 let temp_dir = tempdir().unwrap();
2243 let base_path = temp_dir.path();
2244
2245 let md_file = base_path.join("page.md");
2246 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2247
2248 let content = "[Page](page.htm)";
2249
2250 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2251 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2252 let result = rule.check(&ctx).unwrap();
2253
2254 assert!(
2255 result.is_empty(),
2256 "Should not warn when .md source exists for .htm link"
2257 );
2258 }
2259
2260 #[test]
2261 fn test_html_link_finds_various_markdown_extensions() {
2262 let temp_dir = tempdir().unwrap();
2264 let base_path = temp_dir.path();
2265
2266 File::create(base_path.join("doc.md")).unwrap();
2267 File::create(base_path.join("tutorial.mdx")).unwrap();
2268 File::create(base_path.join("guide.markdown")).unwrap();
2269
2270 let content = r#"
2271[Doc](doc.html)
2272[Tutorial](tutorial.html)
2273[Guide](guide.html)
2274"#;
2275
2276 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2277 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2278 let result = rule.check(&ctx).unwrap();
2279
2280 assert!(
2281 result.is_empty(),
2282 "Should find all markdown variants as source files. Got: {result:?}"
2283 );
2284 }
2285
2286 #[test]
2287 fn test_html_link_in_subdirectory() {
2288 let temp_dir = tempdir().unwrap();
2290 let base_path = temp_dir.path();
2291
2292 let docs_dir = base_path.join("docs");
2293 std::fs::create_dir(&docs_dir).unwrap();
2294 File::create(docs_dir.join("guide.md"))
2295 .unwrap()
2296 .write_all(b"# Guide")
2297 .unwrap();
2298
2299 let content = "[Guide](docs/guide.html)";
2300
2301 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2302 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2303 let result = rule.check(&ctx).unwrap();
2304
2305 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2306 }
2307
2308 #[test]
2309 fn test_absolute_path_skipped_in_check() {
2310 let temp_dir = tempdir().unwrap();
2313 let base_path = temp_dir.path();
2314
2315 let content = r#"
2316# Test Document
2317
2318[Go Runtime](/pkg/runtime)
2319[Go Runtime with Fragment](/pkg/runtime#section)
2320[API Docs](/api/v1/users)
2321[Blog Post](/blog/2024/release.html)
2322[React Hook](/react/hooks/use-state.html)
2323"#;
2324
2325 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2326 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2327 let result = rule.check(&ctx).unwrap();
2328
2329 assert!(
2331 result.is_empty(),
2332 "Absolute paths should be skipped. Got warnings: {result:?}"
2333 );
2334 }
2335
2336 #[test]
2337 fn test_absolute_path_skipped_in_cross_file_check() {
2338 use crate::workspace_index::WorkspaceIndex;
2340
2341 let rule = MD057ExistingRelativeLinks::new();
2342
2343 let workspace_index = WorkspaceIndex::new();
2345
2346 let mut file_index = FileIndex::new();
2348 file_index.add_cross_file_link(CrossFileLinkIndex {
2349 target_path: "/pkg/runtime.md".to_string(),
2350 fragment: "".to_string(),
2351 line: 5,
2352 column: 1,
2353 });
2354 file_index.add_cross_file_link(CrossFileLinkIndex {
2355 target_path: "/api/v1/users.md".to_string(),
2356 fragment: "section".to_string(),
2357 line: 10,
2358 column: 1,
2359 });
2360
2361 let warnings = rule
2363 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2364 .unwrap();
2365
2366 assert!(
2368 warnings.is_empty(),
2369 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2370 );
2371 }
2372
2373 #[test]
2374 fn test_protocol_relative_url_not_skipped() {
2375 let temp_dir = tempdir().unwrap();
2378 let base_path = temp_dir.path();
2379
2380 let content = r#"
2381# Test Document
2382
2383[External](//example.com/page)
2384[Another](//cdn.example.com/asset.js)
2385"#;
2386
2387 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2388 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2389 let result = rule.check(&ctx).unwrap();
2390
2391 assert!(
2393 result.is_empty(),
2394 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2395 );
2396 }
2397
2398 #[test]
2399 fn test_email_addresses_skipped() {
2400 let temp_dir = tempdir().unwrap();
2403 let base_path = temp_dir.path();
2404
2405 let content = r#"
2406# Test Document
2407
2408[Contact](user@example.com)
2409[Steering](steering@kubernetes.io)
2410[Support](john.doe+filter@company.co.uk)
2411[User](user_name@sub.domain.com)
2412"#;
2413
2414 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2415 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2416 let result = rule.check(&ctx).unwrap();
2417
2418 assert!(
2420 result.is_empty(),
2421 "Email addresses should be skipped. Got warnings: {result:?}"
2422 );
2423 }
2424
2425 #[test]
2426 fn test_email_addresses_vs_file_paths() {
2427 let temp_dir = tempdir().unwrap();
2430 let base_path = temp_dir.path();
2431
2432 let content = r#"
2433# Test Document
2434
2435[Email](user@example.com) <!-- Should be skipped (email) -->
2436[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2437[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2438"#;
2439
2440 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2441 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2442 let result = rule.check(&ctx).unwrap();
2443
2444 assert!(
2446 result.is_empty(),
2447 "All email addresses should be skipped. Got: {result:?}"
2448 );
2449 }
2450
2451 #[test]
2452 fn test_diagnostic_position_accuracy() {
2453 let temp_dir = tempdir().unwrap();
2455 let base_path = temp_dir.path();
2456
2457 let content = "prefix [text](missing.md) suffix";
2460 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2464 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2465 let result = rule.check(&ctx).unwrap();
2466
2467 assert_eq!(result.len(), 1, "Should have exactly one warning");
2468 assert_eq!(result[0].line, 1, "Should be on line 1");
2469 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2470 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2471 }
2472
2473 #[test]
2474 fn test_diagnostic_position_angle_brackets() {
2475 let temp_dir = tempdir().unwrap();
2477 let base_path = temp_dir.path();
2478
2479 let content = "[link](<missing.md>)";
2482 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2486 let result = rule.check(&ctx).unwrap();
2487
2488 assert_eq!(result.len(), 1, "Should have exactly one warning");
2489 assert_eq!(result[0].line, 1, "Should be on line 1");
2490 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2491 }
2492
2493 #[test]
2494 fn test_diagnostic_position_multiline() {
2495 let temp_dir = tempdir().unwrap();
2497 let base_path = temp_dir.path();
2498
2499 let content = r#"# Title
2500Some text on line 2
2501[link on line 3](missing1.md)
2502More text
2503[link on line 5](missing2.md)"#;
2504
2505 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2506 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let result = rule.check(&ctx).unwrap();
2508
2509 assert_eq!(result.len(), 2, "Should have two warnings");
2510
2511 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2513 assert!(result[0].message.contains("missing1.md"));
2514
2515 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2517 assert!(result[1].message.contains("missing2.md"));
2518 }
2519
2520 #[test]
2521 fn test_diagnostic_position_with_spaces() {
2522 let temp_dir = tempdir().unwrap();
2524 let base_path = temp_dir.path();
2525
2526 let content = "[link]( missing.md )";
2527 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2532 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2533 let result = rule.check(&ctx).unwrap();
2534
2535 assert_eq!(result.len(), 1, "Should have exactly one warning");
2536 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2538 }
2539
2540 #[test]
2541 fn test_diagnostic_position_image() {
2542 let temp_dir = tempdir().unwrap();
2544 let base_path = temp_dir.path();
2545
2546 let content = "";
2547
2548 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2549 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2550 let result = rule.check(&ctx).unwrap();
2551
2552 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2553 assert_eq!(result[0].line, 1);
2554 assert!(result[0].column > 0, "Should have valid column position");
2556 assert!(result[0].message.contains("missing.jpg"));
2557 }
2558
2559 #[test]
2560 fn test_wikilinks_skipped() {
2561 let temp_dir = tempdir().unwrap();
2564 let base_path = temp_dir.path();
2565
2566 let content = r#"# Test Document
2567
2568[[Microsoft#Windows OS]]
2569[[SomePage]]
2570[[Page With Spaces]]
2571[[path/to/page#section]]
2572[[page|Display Text]]
2573
2574This is a [real missing link](missing.md) that should be flagged.
2575"#;
2576
2577 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2578 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2579 let result = rule.check(&ctx).unwrap();
2580
2581 assert_eq!(
2583 result.len(),
2584 1,
2585 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2586 );
2587 assert!(
2588 result[0].message.contains("missing.md"),
2589 "Warning should be for missing.md, not wikilinks"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_wikilinks_not_added_to_index() {
2595 let temp_dir = tempdir().unwrap();
2597 let base_path = temp_dir.path();
2598
2599 let content = r#"# Test Document
2600
2601[[Microsoft#Windows OS]]
2602[[SomePage#section]]
2603[Regular Link](other.md)
2604"#;
2605
2606 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2607 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2608
2609 let mut file_index = FileIndex::new();
2610 rule.contribute_to_index(&ctx, &mut file_index);
2611
2612 let cross_file_links = &file_index.cross_file_links;
2615 assert_eq!(
2616 cross_file_links.len(),
2617 1,
2618 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2619 );
2620 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2621 }
2622
2623 #[test]
2624 fn test_reference_definition_missing_file() {
2625 let temp_dir = tempdir().unwrap();
2627 let base_path = temp_dir.path();
2628
2629 let content = r#"# Test Document
2630
2631[test]: ./missing.md
2632[example]: ./nonexistent.html
2633
2634Use [test] and [example] here.
2635"#;
2636
2637 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2638 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2639 let result = rule.check(&ctx).unwrap();
2640
2641 assert_eq!(
2643 result.len(),
2644 2,
2645 "Should have warnings for missing reference definition targets. Got: {result:?}"
2646 );
2647 assert!(
2648 result.iter().any(|w| w.message.contains("missing.md")),
2649 "Should warn about missing.md"
2650 );
2651 assert!(
2652 result.iter().any(|w| w.message.contains("nonexistent.html")),
2653 "Should warn about nonexistent.html"
2654 );
2655 }
2656
2657 #[test]
2658 fn test_reference_definition_existing_file() {
2659 let temp_dir = tempdir().unwrap();
2661 let base_path = temp_dir.path();
2662
2663 let exists_path = base_path.join("exists.md");
2665 File::create(&exists_path)
2666 .unwrap()
2667 .write_all(b"# Existing file")
2668 .unwrap();
2669
2670 let content = r#"# Test Document
2671
2672[test]: ./exists.md
2673
2674Use [test] here.
2675"#;
2676
2677 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2678 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2679 let result = rule.check(&ctx).unwrap();
2680
2681 assert!(
2683 result.is_empty(),
2684 "Should not warn about existing file. Got: {result:?}"
2685 );
2686 }
2687
2688 #[test]
2689 fn test_reference_definition_external_url_skipped() {
2690 let temp_dir = tempdir().unwrap();
2692 let base_path = temp_dir.path();
2693
2694 let content = r#"# Test Document
2695
2696[google]: https://google.com
2697[example]: http://example.org
2698[mail]: mailto:test@example.com
2699[ftp]: ftp://files.example.com
2700[local]: ./missing.md
2701
2702Use [google], [example], [mail], [ftp], [local] here.
2703"#;
2704
2705 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2706 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2707 let result = rule.check(&ctx).unwrap();
2708
2709 assert_eq!(
2711 result.len(),
2712 1,
2713 "Should only warn about local missing file. Got: {result:?}"
2714 );
2715 assert!(
2716 result[0].message.contains("missing.md"),
2717 "Warning should be for missing.md"
2718 );
2719 }
2720
2721 #[test]
2722 fn test_reference_definition_fragment_only_skipped() {
2723 let temp_dir = tempdir().unwrap();
2725 let base_path = temp_dir.path();
2726
2727 let content = r#"# Test Document
2728
2729[section]: #my-section
2730
2731Use [section] here.
2732"#;
2733
2734 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2736 let result = rule.check(&ctx).unwrap();
2737
2738 assert!(
2740 result.is_empty(),
2741 "Should not warn about fragment-only reference. Got: {result:?}"
2742 );
2743 }
2744
2745 #[test]
2746 fn test_reference_definition_column_position() {
2747 let temp_dir = tempdir().unwrap();
2749 let base_path = temp_dir.path();
2750
2751 let content = "[ref]: ./missing.md";
2754 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2759 let result = rule.check(&ctx).unwrap();
2760
2761 assert_eq!(result.len(), 1, "Should have exactly one warning");
2762 assert_eq!(result[0].line, 1, "Should be on line 1");
2763 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2764 }
2765
2766 #[test]
2767 fn test_reference_definition_html_with_md_source() {
2768 let temp_dir = tempdir().unwrap();
2770 let base_path = temp_dir.path();
2771
2772 let md_file = base_path.join("guide.md");
2774 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2775
2776 let content = r#"# Test Document
2777
2778[guide]: ./guide.html
2779[missing]: ./missing.html
2780
2781Use [guide] and [missing] here.
2782"#;
2783
2784 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2786 let result = rule.check(&ctx).unwrap();
2787
2788 assert_eq!(
2790 result.len(),
2791 1,
2792 "Should only warn about missing source. Got: {result:?}"
2793 );
2794 assert!(result[0].message.contains("missing.html"));
2795 }
2796
2797 #[test]
2798 fn test_reference_definition_url_encoded() {
2799 let temp_dir = tempdir().unwrap();
2801 let base_path = temp_dir.path();
2802
2803 let file_with_spaces = base_path.join("file with spaces.md");
2805 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2806
2807 let content = r#"# Test Document
2808
2809[spaces]: ./file%20with%20spaces.md
2810[missing]: ./missing%20file.md
2811
2812Use [spaces] and [missing] here.
2813"#;
2814
2815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2816 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2817 let result = rule.check(&ctx).unwrap();
2818
2819 assert_eq!(
2821 result.len(),
2822 1,
2823 "Should only warn about missing URL-encoded file. Got: {result:?}"
2824 );
2825 assert!(result[0].message.contains("missing%20file.md"));
2826 }
2827
2828 #[test]
2829 fn test_inline_and_reference_both_checked() {
2830 let temp_dir = tempdir().unwrap();
2832 let base_path = temp_dir.path();
2833
2834 let content = r#"# Test Document
2835
2836[inline link](./inline-missing.md)
2837[ref]: ./ref-missing.md
2838
2839Use [ref] here.
2840"#;
2841
2842 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2843 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2844 let result = rule.check(&ctx).unwrap();
2845
2846 assert_eq!(
2848 result.len(),
2849 2,
2850 "Should warn about both inline and reference links. Got: {result:?}"
2851 );
2852 assert!(
2853 result.iter().any(|w| w.message.contains("inline-missing.md")),
2854 "Should warn about inline-missing.md"
2855 );
2856 assert!(
2857 result.iter().any(|w| w.message.contains("ref-missing.md")),
2858 "Should warn about ref-missing.md"
2859 );
2860 }
2861
2862 #[test]
2863 fn test_footnote_definitions_not_flagged() {
2864 let rule = MD057ExistingRelativeLinks::default();
2867
2868 let content = r#"# Title
2869
2870A footnote[^1].
2871
2872[^1]: [link](https://www.google.com).
2873"#;
2874
2875 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2876 let result = rule.check(&ctx).unwrap();
2877
2878 assert!(
2879 result.is_empty(),
2880 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2881 );
2882 }
2883
2884 #[test]
2885 fn test_footnote_with_relative_link_inside() {
2886 let rule = MD057ExistingRelativeLinks::default();
2889
2890 let content = r#"# Title
2891
2892See the footnote[^1].
2893
2894[^1]: Check out [this file](./existing.md) for more info.
2895[^2]: Also see [missing](./does-not-exist.md).
2896"#;
2897
2898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let result = rule.check(&ctx).unwrap();
2900
2901 for warning in &result {
2906 assert!(
2907 !warning.message.contains("[this file]"),
2908 "Footnote content should not be treated as URL: {warning:?}"
2909 );
2910 assert!(
2911 !warning.message.contains("[missing]"),
2912 "Footnote content should not be treated as URL: {warning:?}"
2913 );
2914 }
2915 }
2916
2917 #[test]
2918 fn test_mixed_footnotes_and_reference_definitions() {
2919 let temp_dir = tempdir().unwrap();
2921 let base_path = temp_dir.path();
2922
2923 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2924
2925 let content = r#"# Title
2926
2927A footnote[^1] and a [ref link][myref].
2928
2929[^1]: This is a footnote with [link](https://example.com).
2930
2931[myref]: ./missing-file.md "This should be checked"
2932"#;
2933
2934 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2935 let result = rule.check(&ctx).unwrap();
2936
2937 assert_eq!(
2939 result.len(),
2940 1,
2941 "Should only warn about the regular reference definition. Got: {result:?}"
2942 );
2943 assert!(
2944 result[0].message.contains("missing-file.md"),
2945 "Should warn about missing-file.md in reference definition"
2946 );
2947 }
2948
2949 #[test]
2950 fn test_absolute_links_ignore_by_default() {
2951 let temp_dir = tempdir().unwrap();
2953 let base_path = temp_dir.path();
2954
2955 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2956
2957 let content = r#"# Links
2958
2959[API docs](/api/v1/users)
2960[Blog post](/blog/2024/release.html)
2961
2962
2963[ref]: /docs/reference.md
2964"#;
2965
2966 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2967 let result = rule.check(&ctx).unwrap();
2968
2969 assert!(
2971 result.is_empty(),
2972 "Absolute links should be ignored by default. Got: {result:?}"
2973 );
2974 }
2975
2976 #[test]
2977 fn test_absolute_links_warn_config() {
2978 let temp_dir = tempdir().unwrap();
2980 let base_path = temp_dir.path();
2981
2982 let config = MD057Config {
2983 absolute_links: AbsoluteLinksOption::Warn,
2984 ..Default::default()
2985 };
2986 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2987
2988 let content = r#"# Links
2989
2990[API docs](/api/v1/users)
2991[Blog post](/blog/2024/release.html)
2992"#;
2993
2994 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2995 let result = rule.check(&ctx).unwrap();
2996
2997 assert_eq!(
2999 result.len(),
3000 2,
3001 "Should warn about both absolute links. Got: {result:?}"
3002 );
3003 assert!(
3004 result[0].message.contains("cannot be validated locally"),
3005 "Warning should explain why: {}",
3006 result[0].message
3007 );
3008 assert!(
3009 result[0].message.contains("/api/v1/users"),
3010 "Warning should include the link path"
3011 );
3012 }
3013
3014 #[test]
3015 fn test_absolute_links_warn_images() {
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#"# Images
3027
3028
3029"#;
3030
3031 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3032 let result = rule.check(&ctx).unwrap();
3033
3034 assert_eq!(
3035 result.len(),
3036 1,
3037 "Should warn about absolute image path. Got: {result:?}"
3038 );
3039 assert!(
3040 result[0].message.contains("/assets/logo.png"),
3041 "Warning should include the image path"
3042 );
3043 }
3044
3045 #[test]
3046 fn test_absolute_links_warn_reference_definitions() {
3047 let temp_dir = tempdir().unwrap();
3049 let base_path = temp_dir.path();
3050
3051 let config = MD057Config {
3052 absolute_links: AbsoluteLinksOption::Warn,
3053 ..Default::default()
3054 };
3055 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3056
3057 let content = r#"# Reference
3058
3059See the [docs][ref].
3060
3061[ref]: /docs/reference.md
3062"#;
3063
3064 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3065 let result = rule.check(&ctx).unwrap();
3066
3067 assert_eq!(
3068 result.len(),
3069 1,
3070 "Should warn about absolute reference definition. Got: {result:?}"
3071 );
3072 assert!(
3073 result[0].message.contains("/docs/reference.md"),
3074 "Warning should include the reference path"
3075 );
3076 }
3077
3078 #[test]
3079 fn test_search_paths_inline_link() {
3080 let temp_dir = tempdir().unwrap();
3081 let base_path = temp_dir.path();
3082
3083 let assets_dir = base_path.join("assets");
3085 std::fs::create_dir_all(&assets_dir).unwrap();
3086 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3087
3088 let config = MD057Config {
3089 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3090 ..Default::default()
3091 };
3092 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3093
3094 let content = "# Test\n\n[Photo](photo.png)\n";
3095 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3096 let result = rule.check(&ctx).unwrap();
3097
3098 assert!(
3099 result.is_empty(),
3100 "Should find photo.png via search-paths. Got: {result:?}"
3101 );
3102 }
3103
3104 #[test]
3105 fn test_search_paths_image() {
3106 let temp_dir = tempdir().unwrap();
3107 let base_path = temp_dir.path();
3108
3109 let assets_dir = base_path.join("attachments");
3110 std::fs::create_dir_all(&assets_dir).unwrap();
3111 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3112
3113 let config = MD057Config {
3114 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3115 ..Default::default()
3116 };
3117 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3118
3119 let content = "# Test\n\n\n";
3120 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3121 let result = rule.check(&ctx).unwrap();
3122
3123 assert!(
3124 result.is_empty(),
3125 "Should find diagram.svg via search-paths. Got: {result:?}"
3126 );
3127 }
3128
3129 #[test]
3130 fn test_search_paths_reference_definition() {
3131 let temp_dir = tempdir().unwrap();
3132 let base_path = temp_dir.path();
3133
3134 let assets_dir = base_path.join("images");
3135 std::fs::create_dir_all(&assets_dir).unwrap();
3136 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3137
3138 let config = MD057Config {
3139 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3140 ..Default::default()
3141 };
3142 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3143
3144 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3145 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3146 let result = rule.check(&ctx).unwrap();
3147
3148 assert!(
3149 result.is_empty(),
3150 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3151 );
3152 }
3153
3154 #[test]
3155 fn test_search_paths_still_warns_when_truly_missing() {
3156 let temp_dir = tempdir().unwrap();
3157 let base_path = temp_dir.path();
3158
3159 let assets_dir = base_path.join("assets");
3160 std::fs::create_dir_all(&assets_dir).unwrap();
3161
3162 let config = MD057Config {
3163 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3164 ..Default::default()
3165 };
3166 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3167
3168 let content = "# Test\n\n\n";
3169 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3170 let result = rule.check(&ctx).unwrap();
3171
3172 assert_eq!(
3173 result.len(),
3174 1,
3175 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3176 );
3177 }
3178
3179 #[test]
3180 fn test_search_paths_nonexistent_directory() {
3181 let temp_dir = tempdir().unwrap();
3182 let base_path = temp_dir.path();
3183
3184 let config = MD057Config {
3185 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3186 ..Default::default()
3187 };
3188 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3189
3190 let content = "# Test\n\n\n";
3191 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3192 let result = rule.check(&ctx).unwrap();
3193
3194 assert_eq!(
3195 result.len(),
3196 1,
3197 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3198 );
3199 }
3200
3201 #[test]
3202 fn test_obsidian_attachment_folder_named() {
3203 let temp_dir = tempdir().unwrap();
3204 let vault = temp_dir.path().join("vault");
3205 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3206 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3207 std::fs::create_dir_all(vault.join("notes")).unwrap();
3208
3209 std::fs::write(
3210 vault.join(".obsidian/app.json"),
3211 r#"{"attachmentFolderPath": "Attachments"}"#,
3212 )
3213 .unwrap();
3214 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3215
3216 let notes_dir = vault.join("notes");
3217 let source_file = notes_dir.join("test.md");
3218 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3219
3220 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3221
3222 let content = "# Test\n\n\n";
3223 let ctx =
3224 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3225 let result = rule.check(&ctx).unwrap();
3226
3227 assert!(
3228 result.is_empty(),
3229 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3230 );
3231 }
3232
3233 #[test]
3234 fn test_obsidian_attachment_same_folder_as_file() {
3235 let temp_dir = tempdir().unwrap();
3236 let vault = temp_dir.path().join("vault-rf");
3237 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3238 std::fs::create_dir_all(vault.join("notes")).unwrap();
3239
3240 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3241
3242 let notes_dir = vault.join("notes");
3244 let source_file = notes_dir.join("test.md");
3245 std::fs::write(&source_file, "placeholder").unwrap();
3246 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3247
3248 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3249
3250 let content = "# Test\n\n\n";
3251 let ctx =
3252 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3253 let result = rule.check(&ctx).unwrap();
3254
3255 assert!(
3256 result.is_empty(),
3257 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3258 );
3259 }
3260
3261 #[test]
3262 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3263 let temp_dir = tempdir().unwrap();
3264 let vault = temp_dir.path().join("vault-nf");
3265 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3266 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3267 std::fs::create_dir_all(vault.join("notes")).unwrap();
3268
3269 std::fs::write(
3270 vault.join(".obsidian/app.json"),
3271 r#"{"attachmentFolderPath": "Attachments"}"#,
3272 )
3273 .unwrap();
3274 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3275
3276 let notes_dir = vault.join("notes");
3277 let source_file = notes_dir.join("test.md");
3278 std::fs::write(&source_file, "placeholder").unwrap();
3279
3280 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3281
3282 let content = "# Test\n\n\n";
3283 let ctx =
3285 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3286 let result = rule.check(&ctx).unwrap();
3287
3288 assert_eq!(
3289 result.len(),
3290 1,
3291 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3292 );
3293 }
3294
3295 #[test]
3296 fn test_search_paths_combined_with_obsidian() {
3297 let temp_dir = tempdir().unwrap();
3298 let vault = temp_dir.path().join("vault-combo");
3299 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3300 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3301 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3302 std::fs::create_dir_all(vault.join("notes")).unwrap();
3303
3304 std::fs::write(
3305 vault.join(".obsidian/app.json"),
3306 r#"{"attachmentFolderPath": "Attachments"}"#,
3307 )
3308 .unwrap();
3309 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3310 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3311
3312 let notes_dir = vault.join("notes");
3313 let source_file = notes_dir.join("test.md");
3314 std::fs::write(&source_file, "placeholder").unwrap();
3315
3316 let extra_assets_dir = vault.join("extra-assets");
3317 let config = MD057Config {
3318 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3319 ..Default::default()
3320 };
3321 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3322
3323 let content = "# Test\n\n\n\n\n";
3325 let ctx =
3326 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3327 let result = rule.check(&ctx).unwrap();
3328
3329 assert!(
3330 result.is_empty(),
3331 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3332 );
3333 }
3334
3335 #[test]
3336 fn test_obsidian_attachment_subfolder_under_file() {
3337 let temp_dir = tempdir().unwrap();
3338 let vault = temp_dir.path().join("vault-sub");
3339 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3340 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3341
3342 std::fs::write(
3343 vault.join(".obsidian/app.json"),
3344 r#"{"attachmentFolderPath": "./assets"}"#,
3345 )
3346 .unwrap();
3347 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3348
3349 let notes_dir = vault.join("notes");
3350 let source_file = notes_dir.join("test.md");
3351 std::fs::write(&source_file, "placeholder").unwrap();
3352
3353 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3354
3355 let content = "# Test\n\n\n";
3356 let ctx =
3357 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3358 let result = rule.check(&ctx).unwrap();
3359
3360 assert!(
3361 result.is_empty(),
3362 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3363 );
3364 }
3365
3366 #[test]
3367 fn test_obsidian_attachment_vault_root() {
3368 let temp_dir = tempdir().unwrap();
3369 let vault = temp_dir.path().join("vault-root");
3370 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3371 std::fs::create_dir_all(vault.join("notes")).unwrap();
3372
3373 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3375 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3376
3377 let notes_dir = vault.join("notes");
3378 let source_file = notes_dir.join("test.md");
3379 std::fs::write(&source_file, "placeholder").unwrap();
3380
3381 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3382
3383 let content = "# Test\n\n\n";
3384 let ctx =
3385 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3386 let result = rule.check(&ctx).unwrap();
3387
3388 assert!(
3389 result.is_empty(),
3390 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3391 );
3392 }
3393
3394 #[test]
3395 fn test_search_paths_multiple_directories() {
3396 let temp_dir = tempdir().unwrap();
3397 let base_path = temp_dir.path();
3398
3399 let dir_a = base_path.join("dir-a");
3400 let dir_b = base_path.join("dir-b");
3401 std::fs::create_dir_all(&dir_a).unwrap();
3402 std::fs::create_dir_all(&dir_b).unwrap();
3403 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3404 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3405
3406 let config = MD057Config {
3407 search_paths: vec![
3408 dir_a.to_string_lossy().into_owned(),
3409 dir_b.to_string_lossy().into_owned(),
3410 ],
3411 ..Default::default()
3412 };
3413 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3414
3415 let content = "# Test\n\n\n\n\n";
3416 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3417 let result = rule.check(&ctx).unwrap();
3418
3419 assert!(
3420 result.is_empty(),
3421 "Should find files across multiple search paths. Got: {result:?}"
3422 );
3423 }
3424
3425 #[test]
3426 fn test_cross_file_check_with_search_paths() {
3427 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3428
3429 let temp_dir = tempdir().unwrap();
3430 let base_path = temp_dir.path();
3431
3432 let docs_dir = base_path.join("docs");
3434 std::fs::create_dir_all(&docs_dir).unwrap();
3435 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3436
3437 let config = MD057Config {
3438 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3439 ..Default::default()
3440 };
3441 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3442
3443 let file_path = base_path.join("README.md");
3444 std::fs::write(&file_path, "# Readme\n").unwrap();
3445
3446 let mut file_index = FileIndex::default();
3447 file_index.cross_file_links.push(CrossFileLinkIndex {
3448 target_path: "guide.md".to_string(),
3449 fragment: String::new(),
3450 line: 3,
3451 column: 1,
3452 });
3453
3454 let workspace_index = WorkspaceIndex::new();
3455
3456 let result = rule
3457 .cross_file_check(&file_path, &file_index, &workspace_index)
3458 .unwrap();
3459
3460 assert!(
3461 result.is_empty(),
3462 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3463 );
3464 }
3465
3466 #[test]
3467 fn test_cross_file_check_with_obsidian_flavor() {
3468 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3469
3470 let temp_dir = tempdir().unwrap();
3471 let vault = temp_dir.path().join("vault-xf");
3472 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3473 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3474 std::fs::create_dir_all(vault.join("notes")).unwrap();
3475
3476 std::fs::write(
3477 vault.join(".obsidian/app.json"),
3478 r#"{"attachmentFolderPath": "Attachments"}"#,
3479 )
3480 .unwrap();
3481 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3482
3483 let notes_dir = vault.join("notes");
3484 let file_path = notes_dir.join("test.md");
3485 std::fs::write(&file_path, "placeholder").unwrap();
3486
3487 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3488 .with_path(¬es_dir)
3489 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3490
3491 let mut file_index = FileIndex::default();
3492 file_index.cross_file_links.push(CrossFileLinkIndex {
3493 target_path: "ref.md".to_string(),
3494 fragment: String::new(),
3495 line: 3,
3496 column: 1,
3497 });
3498
3499 let workspace_index = WorkspaceIndex::new();
3500
3501 let result = rule
3502 .cross_file_check(&file_path, &file_index, &workspace_index)
3503 .unwrap();
3504
3505 assert!(
3506 result.is_empty(),
3507 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3508 );
3509 }
3510
3511 #[test]
3512 fn test_check_clears_stale_cache() {
3513 let temp_dir = tempdir().unwrap();
3516 let base_path = temp_dir.path();
3517
3518 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3519
3520 let phantom_path = base_path.join("phantom.md");
3522 {
3523 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3524 cache.insert(phantom_path.clone(), true);
3525 }
3526
3527 let content = "[phantom](phantom.md)\n";
3528 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3529 let warnings = rule.check(&ctx).unwrap();
3530
3531 assert_eq!(
3533 warnings.len(),
3534 1,
3535 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3536 );
3537 assert!(warnings[0].message.contains("phantom.md"));
3538 }
3539
3540 #[test]
3541 fn test_check_does_not_carry_over_cache_between_runs() {
3542 let temp_dir = tempdir().unwrap();
3544 let base_path = temp_dir.path();
3545
3546 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3547
3548 let content = "[missing](nonexistent.md)\n";
3549 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3550
3551 let warnings_1 = rule.check(&ctx).unwrap();
3553 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3554
3555 let nonexistent_path = base_path.join("nonexistent.md");
3557 {
3558 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3559 cache.insert(nonexistent_path.clone(), true);
3560 }
3561
3562 let warnings_2 = rule.check(&ctx).unwrap();
3564 assert_eq!(
3565 warnings_2.len(),
3566 1,
3567 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3568 );
3569 }
3570
3571 #[test]
3577 fn test_no_duplicate_warnings_for_broken_relative_link() {
3578 use crate::workspace_index::WorkspaceIndex;
3579
3580 let temp_dir = tempdir().unwrap();
3581 let base_path = temp_dir.path();
3582
3583 let source_file = base_path.join("index.md");
3585 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3586
3587 let content = "[broken](does/not/exist.md)\n";
3588
3589 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3590
3591 let ctx = crate::lint_context::LintContext::new(
3593 content,
3594 crate::config::MarkdownFlavor::Standard,
3595 Some(source_file.clone()),
3596 );
3597 let check_warnings = rule.check(&ctx).unwrap();
3598
3599 let mut file_index = FileIndex::new();
3601 rule.contribute_to_index(&ctx, &mut file_index);
3602 let workspace_index = WorkspaceIndex::new();
3603 let cross_warnings = rule
3604 .cross_file_check(&source_file, &file_index, &workspace_index)
3605 .unwrap();
3606
3607 let total = check_warnings.len() + cross_warnings.len();
3608 assert_eq!(
3609 total, 1,
3610 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3611 check={check_warnings:?}, cross={cross_warnings:?}"
3612 );
3613 }
3614
3615 #[test]
3620 fn test_absolute_dir_link_accepted_relative_to_roots() {
3621 let temp_dir = tempdir().unwrap();
3622 let root = temp_dir.path();
3623
3624 let dir_d = root.join("d");
3626 std::fs::create_dir_all(&dir_d).unwrap();
3627 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3628
3629 let content = "\
3632[absolute dir](/d)\n\
3633[relative dir](d)\n\
3634[absolute file](/d/foo.md)\n\
3635[relative file](d/foo.md)\n";
3636
3637 let config = MD057Config {
3638 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3639 roots: vec![],
3640 ..Default::default()
3641 };
3642 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3643
3644 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3645 let result = rule.check(&ctx).unwrap();
3646
3647 assert!(
3648 result.is_empty(),
3649 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3650 );
3651 }
3652
3653 #[test]
3656 fn test_absolute_trailing_slash_dir_link_requires_index() {
3657 let temp_dir = tempdir().unwrap();
3658 let root = temp_dir.path();
3659
3660 let dir_d = root.join("d");
3662 std::fs::create_dir_all(&dir_d).unwrap();
3663 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3664
3665 let content = "[dir with slash](/d/)\n";
3667
3668 let config = MD057Config {
3669 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3670 roots: vec![],
3671 ..Default::default()
3672 };
3673 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3674
3675 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3676 let result = rule.check(&ctx).unwrap();
3677
3678 assert_eq!(
3679 result.len(),
3680 1,
3681 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3682 );
3683 }
3684
3685 #[test]
3689 fn test_docs_dir_variant_still_enforces_index_md() {
3690 let temp_dir = tempdir().unwrap();
3691 let root = temp_dir.path();
3692
3693 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3695
3696 let docs_dir = root.join("docs");
3698 std::fs::create_dir_all(&docs_dir).unwrap();
3699 let section_dir = docs_dir.join("section");
3700 std::fs::create_dir_all(§ion_dir).unwrap();
3701 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3702
3703 let source_file = docs_dir.join("index.md");
3705 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3706
3707 let config = MD057Config {
3708 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3709 ..Default::default()
3710 };
3711 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3712
3713 let content = "[sec](/section)\n";
3714 let ctx = crate::lint_context::LintContext::new(
3715 content,
3716 crate::config::MarkdownFlavor::Standard,
3717 Some(source_file.clone()),
3718 );
3719 let result = rule.check(&ctx).unwrap();
3720
3721 assert_eq!(
3723 result.len(),
3724 1,
3725 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3726 );
3727 assert!(
3728 result[0].message.contains("index.md") || result[0].message.contains("section"),
3729 "Message should mention the directory or missing index.md: {}",
3730 result[0].message
3731 );
3732 }
3733
3734 #[test]
3740 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3741 let temp_dir = tempdir().unwrap();
3742 let root = temp_dir.path();
3743
3744 let guide_dir = root.join("guide");
3746 std::fs::create_dir_all(&guide_dir).unwrap();
3747 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3748
3749 let content = "[guide with fragment](/guide/#intro)\n";
3751
3752 let config = MD057Config {
3753 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3754 roots: vec![],
3755 ..Default::default()
3756 };
3757 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3759 let result = rule.check(&ctx).unwrap();
3760
3761 assert_eq!(
3762 result.len(),
3763 1,
3764 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3765 );
3766 }
3767}