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 let links = extract_cross_file_links(ctx);
1227 for link in links.relative {
1228 index.add_cross_file_link(link);
1229 }
1230 for link in links.root_relative {
1233 index.add_root_relative_link(link);
1234 }
1235 }
1236
1237 fn cross_file_check(
1238 &self,
1239 _file_path: &Path,
1240 _file_index: &FileIndex,
1241 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1242 ) -> LintResult {
1243 Ok(Vec::new())
1253 }
1254}
1255
1256fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1261 let from_components: Vec<_> = from_dir.components().collect();
1262 let to_components: Vec<_> = to_path.components().collect();
1263
1264 let common_len = from_components
1266 .iter()
1267 .zip(to_components.iter())
1268 .take_while(|(a, b)| a == b)
1269 .count();
1270
1271 let mut result = PathBuf::new();
1272
1273 for _ in common_len..from_components.len() {
1275 result.push("..");
1276 }
1277
1278 for component in &to_components[common_len..] {
1280 result.push(component);
1281 }
1282
1283 result
1284}
1285
1286fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1292 let link_path = Path::new(raw_link_path);
1293
1294 let has_traversal = link_path
1296 .components()
1297 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1298
1299 if !has_traversal {
1300 return None;
1301 }
1302
1303 let combined = source_dir.join(link_path);
1305 let normalized_target = normalize_path(&combined);
1306
1307 let normalized_source = normalize_path(source_dir);
1309 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1310
1311 if shortest != link_path {
1313 let compact = shortest.to_string_lossy().to_string();
1314 if compact.is_empty() {
1316 return None;
1317 }
1318 Some(compact.replace('\\', "/"))
1320 } else {
1321 None
1322 }
1323}
1324
1325fn normalize_path(path: &Path) -> PathBuf {
1327 let mut components = Vec::new();
1328
1329 for component in path.components() {
1330 match component {
1331 std::path::Component::ParentDir => {
1332 if !components.is_empty() {
1334 components.pop();
1335 }
1336 }
1337 std::path::Component::CurDir => {
1338 }
1340 _ => {
1341 components.push(component);
1342 }
1343 }
1344 }
1345
1346 components.iter().collect()
1347}
1348
1349#[cfg(test)]
1350mod tests {
1351 use super::*;
1352 use crate::workspace_index::CrossFileLinkIndex;
1353 use std::fs::File;
1354 use std::io::Write;
1355 use tempfile::tempdir;
1356
1357 #[test]
1358 fn test_strip_query_and_fragment() {
1359 assert_eq!(
1361 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1362 "file.png"
1363 );
1364 assert_eq!(
1365 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1366 "file.png"
1367 );
1368 assert_eq!(
1369 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1370 "file.png"
1371 );
1372
1373 assert_eq!(
1375 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1376 "file.md"
1377 );
1378 assert_eq!(
1379 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1380 "file.md"
1381 );
1382
1383 assert_eq!(
1385 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1386 "file.md"
1387 );
1388
1389 assert_eq!(
1391 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1392 "file.png"
1393 );
1394
1395 assert_eq!(
1397 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1398 "path/to/image.png"
1399 );
1400 assert_eq!(
1401 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1402 "path/to/image.png"
1403 );
1404
1405 assert_eq!(
1407 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1408 "file.md"
1409 );
1410 }
1411
1412 #[test]
1413 fn test_url_decode() {
1414 assert_eq!(
1416 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1417 "penguin with space.jpg"
1418 );
1419
1420 assert_eq!(
1422 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1423 "assets/my file name.png"
1424 );
1425
1426 assert_eq!(
1428 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1429 "hello world!.md"
1430 );
1431
1432 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1434
1435 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1437
1438 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1440
1441 assert_eq!(
1443 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1444 "normal-file.md"
1445 );
1446
1447 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1449
1450 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1452
1453 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1455
1456 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1458
1459 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1461
1462 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1464
1465 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1467
1468 assert_eq!(
1470 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1471 "path/to/file.md"
1472 );
1473
1474 assert_eq!(
1476 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1477 "hello world/foo bar.md"
1478 );
1479
1480 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1482
1483 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1485 }
1486
1487 #[test]
1488 fn test_url_encoded_filenames() {
1489 let temp_dir = tempdir().unwrap();
1491 let base_path = temp_dir.path();
1492
1493 let file_with_spaces = base_path.join("penguin with space.jpg");
1495 File::create(&file_with_spaces)
1496 .unwrap()
1497 .write_all(b"image data")
1498 .unwrap();
1499
1500 let subdir = base_path.join("my images");
1502 std::fs::create_dir(&subdir).unwrap();
1503 let nested_file = subdir.join("photo 1.png");
1504 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1505
1506 let content = r#"
1508# Test Document with URL-Encoded Links
1509
1510
1511
1512
1513"#;
1514
1515 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1516
1517 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1518 let result = rule.check(&ctx).unwrap();
1519
1520 assert_eq!(
1522 result.len(),
1523 1,
1524 "Should only warn about missing%20file.jpg. Got: {result:?}"
1525 );
1526 assert!(
1527 result[0].message.contains("missing%20file.jpg"),
1528 "Warning should mention the URL-encoded filename"
1529 );
1530 }
1531
1532 #[test]
1533 fn test_external_urls() {
1534 let rule = MD057ExistingRelativeLinks::new();
1535
1536 assert!(rule.is_external_url("https://example.com"));
1538 assert!(rule.is_external_url("http://example.com"));
1539 assert!(rule.is_external_url("ftp://example.com"));
1540 assert!(rule.is_external_url("www.example.com"));
1541 assert!(rule.is_external_url("example.com"));
1542
1543 assert!(rule.is_external_url("file:///path/to/file"));
1545 assert!(rule.is_external_url("smb://server/share"));
1546 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1547 assert!(rule.is_external_url("mailto:user@example.com"));
1548 assert!(rule.is_external_url("tel:+1234567890"));
1549 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1550 assert!(rule.is_external_url("javascript:void(0)"));
1551 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1552 assert!(rule.is_external_url("git://github.com/repo.git"));
1553
1554 assert!(rule.is_external_url("user@example.com"));
1557 assert!(rule.is_external_url("steering@kubernetes.io"));
1558 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1559 assert!(rule.is_external_url("user_name@sub.domain.com"));
1560 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1561
1562 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"));
1573 assert!(!rule.is_external_url("/blog/2024/release.html"));
1574 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1575 assert!(!rule.is_external_url("/pkg/runtime"));
1576 assert!(!rule.is_external_url("/doc/go1compat"));
1577 assert!(!rule.is_external_url("/index.html"));
1578 assert!(!rule.is_external_url("/assets/logo.png"));
1579
1580 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1582 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1583 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1584 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1585 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1586
1587 assert!(rule.is_external_url("~/assets/image.png"));
1590 assert!(rule.is_external_url("~/components/Button.vue"));
1591 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1595 assert!(rule.is_external_url("@images/photo.jpg"));
1596 assert!(rule.is_external_url("@assets/styles.css"));
1597
1598 assert!(!rule.is_external_url("./relative/path.md"));
1600 assert!(!rule.is_external_url("relative/path.md"));
1601 assert!(!rule.is_external_url("../parent/path.md"));
1602 }
1603
1604 #[test]
1605 fn test_framework_path_aliases() {
1606 let temp_dir = tempdir().unwrap();
1608 let base_path = temp_dir.path();
1609
1610 let content = r#"
1612# Framework Path Aliases
1613
1614
1615
1616
1617
1618[Link](@/pages/about.md)
1619
1620This is a [real missing link](missing.md) that should be flagged.
1621"#;
1622
1623 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1624
1625 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1626 let result = rule.check(&ctx).unwrap();
1627
1628 assert_eq!(
1630 result.len(),
1631 1,
1632 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1633 );
1634 assert!(
1635 result[0].message.contains("missing.md"),
1636 "Warning should be for missing.md"
1637 );
1638 }
1639
1640 #[test]
1641 fn test_url_decode_security_path_traversal() {
1642 let temp_dir = tempdir().unwrap();
1645 let base_path = temp_dir.path();
1646
1647 let file_in_base = base_path.join("safe.md");
1649 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1650
1651 let content = r#"
1656[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1657[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1658[Safe link](safe.md)
1659"#;
1660
1661 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1662
1663 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664 let result = rule.check(&ctx).unwrap();
1665
1666 assert_eq!(
1669 result.len(),
1670 2,
1671 "Should have warnings for traversal attempts. Got: {result:?}"
1672 );
1673 }
1674
1675 #[test]
1676 fn test_url_encoded_utf8_filenames() {
1677 let temp_dir = tempdir().unwrap();
1679 let base_path = temp_dir.path();
1680
1681 let cafe_file = base_path.join("café.md");
1683 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1684
1685 let content = r#"
1686[Café link](caf%C3%A9.md)
1687[Missing unicode](r%C3%A9sum%C3%A9.md)
1688"#;
1689
1690 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1691
1692 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1693 let result = rule.check(&ctx).unwrap();
1694
1695 assert_eq!(
1697 result.len(),
1698 1,
1699 "Should only warn about missing résumé.md. Got: {result:?}"
1700 );
1701 assert!(
1702 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1703 "Warning should mention the URL-encoded filename"
1704 );
1705 }
1706
1707 #[test]
1708 fn test_url_encoded_emoji_filenames() {
1709 let temp_dir = tempdir().unwrap();
1712 let base_path = temp_dir.path();
1713
1714 let emoji_dir = base_path.join("👤 Personal");
1716 std::fs::create_dir(&emoji_dir).unwrap();
1717
1718 let file_path = emoji_dir.join("TV Shows.md");
1720 File::create(&file_path)
1721 .unwrap()
1722 .write_all(b"# TV Shows\n\nContent here.")
1723 .unwrap();
1724
1725 let content = r#"
1728# Test Document
1729
1730[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1731[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1732"#;
1733
1734 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1735
1736 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1737 let result = rule.check(&ctx).unwrap();
1738
1739 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1741 assert!(
1742 result[0].message.contains("Missing.md"),
1743 "Warning should be for Missing.md, got: {}",
1744 result[0].message
1745 );
1746 }
1747
1748 #[test]
1749 fn test_no_warnings_without_base_path() {
1750 let rule = MD057ExistingRelativeLinks::new();
1751 let content = "[Link](missing.md)";
1752
1753 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1754 let result = rule.check(&ctx).unwrap();
1755 assert!(result.is_empty(), "Should have no warnings without base path");
1756 }
1757
1758 #[test]
1759 fn test_existing_and_missing_links() {
1760 let temp_dir = tempdir().unwrap();
1762 let base_path = temp_dir.path();
1763
1764 let exists_path = base_path.join("exists.md");
1766 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1767
1768 assert!(exists_path.exists(), "exists.md should exist for this test");
1770
1771 let content = r#"
1773# Test Document
1774
1775[Valid Link](exists.md)
1776[Invalid Link](missing.md)
1777[External Link](https://example.com)
1778[Media Link](image.jpg)
1779 "#;
1780
1781 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1783
1784 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1786 let result = rule.check(&ctx).unwrap();
1787
1788 assert_eq!(result.len(), 2);
1790 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1791 assert!(messages.iter().any(|m| m.contains("missing.md")));
1792 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1793 }
1794
1795 #[test]
1796 fn test_angle_bracket_links() {
1797 let temp_dir = tempdir().unwrap();
1799 let base_path = temp_dir.path();
1800
1801 let exists_path = base_path.join("exists.md");
1803 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1804
1805 let content = r#"
1807# Test Document
1808
1809[Valid Link](<exists.md>)
1810[Invalid Link](<missing.md>)
1811[External Link](<https://example.com>)
1812 "#;
1813
1814 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1816
1817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1818 let result = rule.check(&ctx).unwrap();
1819
1820 assert_eq!(result.len(), 1, "Should have exactly one warning");
1822 assert!(
1823 result[0].message.contains("missing.md"),
1824 "Warning should mention missing.md"
1825 );
1826 }
1827
1828 #[test]
1829 fn test_angle_bracket_links_with_parens() {
1830 let temp_dir = tempdir().unwrap();
1832 let base_path = temp_dir.path();
1833
1834 let app_dir = base_path.join("app");
1836 std::fs::create_dir(&app_dir).unwrap();
1837 let upload_dir = app_dir.join("(upload)");
1838 std::fs::create_dir(&upload_dir).unwrap();
1839 let page_file = upload_dir.join("page.tsx");
1840 File::create(&page_file)
1841 .unwrap()
1842 .write_all(b"export default function Page() {}")
1843 .unwrap();
1844
1845 let content = r#"
1847# Test Document with Paths Containing Parens
1848
1849[Upload Page](<app/(upload)/page.tsx>)
1850[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1851[Missing](<app/(missing)/file.md>)
1852"#;
1853
1854 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1855
1856 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1857 let result = rule.check(&ctx).unwrap();
1858
1859 assert_eq!(
1861 result.len(),
1862 1,
1863 "Should have exactly one warning for missing file. Got: {result:?}"
1864 );
1865 assert!(
1866 result[0].message.contains("app/(missing)/file.md"),
1867 "Warning should mention app/(missing)/file.md"
1868 );
1869 }
1870
1871 #[test]
1872 fn test_all_file_types_checked() {
1873 let temp_dir = tempdir().unwrap();
1875 let base_path = temp_dir.path();
1876
1877 let content = r#"
1879[Image Link](image.jpg)
1880[Video Link](video.mp4)
1881[Markdown Link](document.md)
1882[PDF Link](file.pdf)
1883"#;
1884
1885 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1886
1887 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1888 let result = rule.check(&ctx).unwrap();
1889
1890 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1892 }
1893
1894 #[test]
1895 fn test_code_span_detection() {
1896 let rule = MD057ExistingRelativeLinks::new();
1897
1898 let temp_dir = tempdir().unwrap();
1900 let base_path = temp_dir.path();
1901
1902 let rule = rule.with_path(base_path);
1903
1904 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1906
1907 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1908 let result = rule.check(&ctx).unwrap();
1909
1910 assert_eq!(result.len(), 1, "Should only flag the real link");
1912 assert!(result[0].message.contains("nonexistent.md"));
1913 }
1914
1915 #[test]
1916 fn test_inline_code_spans() {
1917 let temp_dir = tempdir().unwrap();
1919 let base_path = temp_dir.path();
1920
1921 let content = r#"
1923# Test Document
1924
1925This is a normal link: [Link](missing.md)
1926
1927This is a code span with a link: `[Link](another-missing.md)`
1928
1929Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1930
1931 "#;
1932
1933 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1935
1936 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1938 let result = rule.check(&ctx).unwrap();
1939
1940 assert_eq!(result.len(), 1, "Should have exactly one warning");
1942 assert!(
1943 result[0].message.contains("missing.md"),
1944 "Warning should be for missing.md"
1945 );
1946 assert!(
1947 !result.iter().any(|w| w.message.contains("another-missing.md")),
1948 "Should not warn about link in code span"
1949 );
1950 assert!(
1951 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1952 "Should not warn about link in inline code"
1953 );
1954 }
1955
1956 #[test]
1957 fn test_extensionless_link_resolution() {
1958 let temp_dir = tempdir().unwrap();
1960 let base_path = temp_dir.path();
1961
1962 let page_path = base_path.join("page.md");
1964 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1965
1966 let content = r#"
1968# Test Document
1969
1970[Link without extension](page)
1971[Link with extension](page.md)
1972[Missing link](nonexistent)
1973"#;
1974
1975 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1976
1977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1978 let result = rule.check(&ctx).unwrap();
1979
1980 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
1983 assert!(
1984 result[0].message.contains("nonexistent"),
1985 "Warning should be for 'nonexistent' not 'page'"
1986 );
1987 }
1988
1989 #[test]
1991 fn test_cross_file_scope() {
1992 let rule = MD057ExistingRelativeLinks::new();
1993 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
1994 }
1995
1996 #[test]
1997 fn test_contribute_to_index_extracts_markdown_links() {
1998 let rule = MD057ExistingRelativeLinks::new();
1999 let content = r#"
2000# Document
2001
2002[Link to docs](./docs/guide.md)
2003[Link with fragment](./other.md#section)
2004[External link](https://example.com)
2005[Image link](image.png)
2006[Media file](video.mp4)
2007"#;
2008
2009 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2010 let mut index = FileIndex::new();
2011 rule.contribute_to_index(&ctx, &mut index);
2012
2013 assert_eq!(index.cross_file_links.len(), 2);
2015
2016 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2018 assert_eq!(index.cross_file_links[0].fragment, "");
2019
2020 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2022 assert_eq!(index.cross_file_links[1].fragment, "section");
2023 }
2024
2025 #[test]
2026 fn test_contribute_to_index_skips_external_and_anchors() {
2027 let rule = MD057ExistingRelativeLinks::new();
2028 let content = r#"
2029# Document
2030
2031[External](https://example.com)
2032[Another external](http://example.org)
2033[Fragment only](#section)
2034[FTP link](ftp://files.example.com)
2035[Mail link](mailto:test@example.com)
2036[WWW link](www.example.com)
2037"#;
2038
2039 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2040 let mut index = FileIndex::new();
2041 rule.contribute_to_index(&ctx, &mut index);
2042
2043 assert_eq!(index.cross_file_links.len(), 0);
2045 }
2046
2047 #[test]
2048 fn test_cross_file_check_valid_link() {
2049 use crate::workspace_index::WorkspaceIndex;
2050
2051 let rule = MD057ExistingRelativeLinks::new();
2052
2053 let mut workspace_index = WorkspaceIndex::new();
2055 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2056
2057 let mut file_index = FileIndex::new();
2059 file_index.add_cross_file_link(CrossFileLinkIndex {
2060 target_path: "guide.md".to_string(),
2061 fragment: "".to_string(),
2062 line: 5,
2063 column: 1,
2064 });
2065
2066 let warnings = rule
2068 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2069 .unwrap();
2070
2071 assert!(warnings.is_empty());
2073 }
2074
2075 #[test]
2076 fn test_cross_file_check_missing_link() {
2077 use crate::workspace_index::WorkspaceIndex;
2080
2081 let rule = MD057ExistingRelativeLinks::new();
2082 let workspace_index = WorkspaceIndex::new();
2083
2084 let mut file_index = FileIndex::new();
2085 file_index.add_cross_file_link(CrossFileLinkIndex {
2086 target_path: "missing.md".to_string(),
2087 fragment: "".to_string(),
2088 line: 5,
2089 column: 1,
2090 });
2091
2092 let warnings = rule
2093 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2094 .unwrap();
2095
2096 assert!(
2098 warnings.is_empty(),
2099 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2100 );
2101 }
2102
2103 #[test]
2104 fn test_cross_file_check_parent_path() {
2105 use crate::workspace_index::WorkspaceIndex;
2106
2107 let rule = MD057ExistingRelativeLinks::new();
2108
2109 let mut workspace_index = WorkspaceIndex::new();
2111 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2112
2113 let mut file_index = FileIndex::new();
2115 file_index.add_cross_file_link(CrossFileLinkIndex {
2116 target_path: "../readme.md".to_string(),
2117 fragment: "".to_string(),
2118 line: 5,
2119 column: 1,
2120 });
2121
2122 let warnings = rule
2124 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2125 .unwrap();
2126
2127 assert!(warnings.is_empty());
2129 }
2130
2131 #[test]
2132 fn test_cross_file_check_html_link_with_md_source() {
2133 use crate::workspace_index::WorkspaceIndex;
2136
2137 let rule = MD057ExistingRelativeLinks::new();
2138
2139 let mut workspace_index = WorkspaceIndex::new();
2141 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2142
2143 let mut file_index = FileIndex::new();
2145 file_index.add_cross_file_link(CrossFileLinkIndex {
2146 target_path: "guide.html".to_string(),
2147 fragment: "section".to_string(),
2148 line: 10,
2149 column: 5,
2150 });
2151
2152 let warnings = rule
2154 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2155 .unwrap();
2156
2157 assert!(
2159 warnings.is_empty(),
2160 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2161 );
2162 }
2163
2164 #[test]
2165 fn test_cross_file_check_html_link_without_source() {
2166 use crate::workspace_index::WorkspaceIndex;
2170
2171 let rule = MD057ExistingRelativeLinks::new();
2172 let workspace_index = WorkspaceIndex::new();
2173
2174 let mut file_index = FileIndex::new();
2175 file_index.add_cross_file_link(CrossFileLinkIndex {
2176 target_path: "missing.html".to_string(),
2177 fragment: "".to_string(),
2178 line: 10,
2179 column: 5,
2180 });
2181
2182 let warnings = rule
2183 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2184 .unwrap();
2185
2186 assert!(
2188 warnings.is_empty(),
2189 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2190 );
2191 }
2192
2193 #[test]
2194 fn test_normalize_path_function() {
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/guide.md")),
2204 PathBuf::from("docs/guide.md")
2205 );
2206
2207 assert_eq!(
2209 normalize_path(Path::new("docs/sub/../guide.md")),
2210 PathBuf::from("docs/guide.md")
2211 );
2212
2213 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2215 }
2216
2217 #[test]
2218 fn test_html_link_with_md_source() {
2219 let temp_dir = tempdir().unwrap();
2221 let base_path = temp_dir.path();
2222
2223 let md_file = base_path.join("guide.md");
2225 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2226
2227 let content = r#"
2228[Read the guide](guide.html)
2229[Also here](getting-started.html)
2230"#;
2231
2232 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2233 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2234 let result = rule.check(&ctx).unwrap();
2235
2236 assert_eq!(
2238 result.len(),
2239 1,
2240 "Should only warn about missing source. Got: {result:?}"
2241 );
2242 assert!(result[0].message.contains("getting-started.html"));
2243 }
2244
2245 #[test]
2246 fn test_htm_link_with_md_source() {
2247 let temp_dir = tempdir().unwrap();
2249 let base_path = temp_dir.path();
2250
2251 let md_file = base_path.join("page.md");
2252 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2253
2254 let content = "[Page](page.htm)";
2255
2256 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2257 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2258 let result = rule.check(&ctx).unwrap();
2259
2260 assert!(
2261 result.is_empty(),
2262 "Should not warn when .md source exists for .htm link"
2263 );
2264 }
2265
2266 #[test]
2267 fn test_html_link_finds_various_markdown_extensions() {
2268 let temp_dir = tempdir().unwrap();
2270 let base_path = temp_dir.path();
2271
2272 File::create(base_path.join("doc.md")).unwrap();
2273 File::create(base_path.join("tutorial.mdx")).unwrap();
2274 File::create(base_path.join("guide.markdown")).unwrap();
2275
2276 let content = r#"
2277[Doc](doc.html)
2278[Tutorial](tutorial.html)
2279[Guide](guide.html)
2280"#;
2281
2282 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2284 let result = rule.check(&ctx).unwrap();
2285
2286 assert!(
2287 result.is_empty(),
2288 "Should find all markdown variants as source files. Got: {result:?}"
2289 );
2290 }
2291
2292 #[test]
2293 fn test_html_link_in_subdirectory() {
2294 let temp_dir = tempdir().unwrap();
2296 let base_path = temp_dir.path();
2297
2298 let docs_dir = base_path.join("docs");
2299 std::fs::create_dir(&docs_dir).unwrap();
2300 File::create(docs_dir.join("guide.md"))
2301 .unwrap()
2302 .write_all(b"# Guide")
2303 .unwrap();
2304
2305 let content = "[Guide](docs/guide.html)";
2306
2307 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2309 let result = rule.check(&ctx).unwrap();
2310
2311 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2312 }
2313
2314 #[test]
2315 fn test_absolute_path_skipped_in_check() {
2316 let temp_dir = tempdir().unwrap();
2319 let base_path = temp_dir.path();
2320
2321 let content = r#"
2322# Test Document
2323
2324[Go Runtime](/pkg/runtime)
2325[Go Runtime with Fragment](/pkg/runtime#section)
2326[API Docs](/api/v1/users)
2327[Blog Post](/blog/2024/release.html)
2328[React Hook](/react/hooks/use-state.html)
2329"#;
2330
2331 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2332 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2333 let result = rule.check(&ctx).unwrap();
2334
2335 assert!(
2337 result.is_empty(),
2338 "Absolute paths should be skipped. Got warnings: {result:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_absolute_path_skipped_in_cross_file_check() {
2344 use crate::workspace_index::WorkspaceIndex;
2346
2347 let rule = MD057ExistingRelativeLinks::new();
2348
2349 let workspace_index = WorkspaceIndex::new();
2351
2352 let mut file_index = FileIndex::new();
2354 file_index.add_cross_file_link(CrossFileLinkIndex {
2355 target_path: "/pkg/runtime.md".to_string(),
2356 fragment: "".to_string(),
2357 line: 5,
2358 column: 1,
2359 });
2360 file_index.add_cross_file_link(CrossFileLinkIndex {
2361 target_path: "/api/v1/users.md".to_string(),
2362 fragment: "section".to_string(),
2363 line: 10,
2364 column: 1,
2365 });
2366
2367 let warnings = rule
2369 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2370 .unwrap();
2371
2372 assert!(
2374 warnings.is_empty(),
2375 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2376 );
2377 }
2378
2379 #[test]
2380 fn test_protocol_relative_url_not_skipped() {
2381 let temp_dir = tempdir().unwrap();
2384 let base_path = temp_dir.path();
2385
2386 let content = r#"
2387# Test Document
2388
2389[External](//example.com/page)
2390[Another](//cdn.example.com/asset.js)
2391"#;
2392
2393 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2394 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2395 let result = rule.check(&ctx).unwrap();
2396
2397 assert!(
2399 result.is_empty(),
2400 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2401 );
2402 }
2403
2404 #[test]
2405 fn test_email_addresses_skipped() {
2406 let temp_dir = tempdir().unwrap();
2409 let base_path = temp_dir.path();
2410
2411 let content = r#"
2412# Test Document
2413
2414[Contact](user@example.com)
2415[Steering](steering@kubernetes.io)
2416[Support](john.doe+filter@company.co.uk)
2417[User](user_name@sub.domain.com)
2418"#;
2419
2420 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2421 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2422 let result = rule.check(&ctx).unwrap();
2423
2424 assert!(
2426 result.is_empty(),
2427 "Email addresses should be skipped. Got warnings: {result:?}"
2428 );
2429 }
2430
2431 #[test]
2432 fn test_email_addresses_vs_file_paths() {
2433 let temp_dir = tempdir().unwrap();
2436 let base_path = temp_dir.path();
2437
2438 let content = r#"
2439# Test Document
2440
2441[Email](user@example.com) <!-- Should be skipped (email) -->
2442[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2443[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2444"#;
2445
2446 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2447 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2448 let result = rule.check(&ctx).unwrap();
2449
2450 assert!(
2452 result.is_empty(),
2453 "All email addresses should be skipped. Got: {result:?}"
2454 );
2455 }
2456
2457 #[test]
2458 fn test_diagnostic_position_accuracy() {
2459 let temp_dir = tempdir().unwrap();
2461 let base_path = temp_dir.path();
2462
2463 let content = "prefix [text](missing.md) suffix";
2466 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2470 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2471 let result = rule.check(&ctx).unwrap();
2472
2473 assert_eq!(result.len(), 1, "Should have exactly one warning");
2474 assert_eq!(result[0].line, 1, "Should be on line 1");
2475 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2476 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2477 }
2478
2479 #[test]
2480 fn test_diagnostic_position_angle_brackets() {
2481 let temp_dir = tempdir().unwrap();
2483 let base_path = temp_dir.path();
2484
2485 let content = "[link](<missing.md>)";
2488 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2491 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2492 let result = rule.check(&ctx).unwrap();
2493
2494 assert_eq!(result.len(), 1, "Should have exactly one warning");
2495 assert_eq!(result[0].line, 1, "Should be on line 1");
2496 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2497 }
2498
2499 #[test]
2500 fn test_diagnostic_position_multiline() {
2501 let temp_dir = tempdir().unwrap();
2503 let base_path = temp_dir.path();
2504
2505 let content = r#"# Title
2506Some text on line 2
2507[link on line 3](missing1.md)
2508More text
2509[link on line 5](missing2.md)"#;
2510
2511 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2512 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2513 let result = rule.check(&ctx).unwrap();
2514
2515 assert_eq!(result.len(), 2, "Should have two warnings");
2516
2517 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2519 assert!(result[0].message.contains("missing1.md"));
2520
2521 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2523 assert!(result[1].message.contains("missing2.md"));
2524 }
2525
2526 #[test]
2527 fn test_diagnostic_position_with_spaces() {
2528 let temp_dir = tempdir().unwrap();
2530 let base_path = temp_dir.path();
2531
2532 let content = "[link]( missing.md )";
2533 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2538 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2539 let result = rule.check(&ctx).unwrap();
2540
2541 assert_eq!(result.len(), 1, "Should have exactly one warning");
2542 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2544 }
2545
2546 #[test]
2547 fn test_diagnostic_position_image() {
2548 let temp_dir = tempdir().unwrap();
2550 let base_path = temp_dir.path();
2551
2552 let content = "";
2553
2554 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2555 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2556 let result = rule.check(&ctx).unwrap();
2557
2558 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2559 assert_eq!(result[0].line, 1);
2560 assert!(result[0].column > 0, "Should have valid column position");
2562 assert!(result[0].message.contains("missing.jpg"));
2563 }
2564
2565 #[test]
2566 fn test_wikilinks_skipped() {
2567 let temp_dir = tempdir().unwrap();
2570 let base_path = temp_dir.path();
2571
2572 let content = r#"# Test Document
2573
2574[[Microsoft#Windows OS]]
2575[[SomePage]]
2576[[Page With Spaces]]
2577[[path/to/page#section]]
2578[[page|Display Text]]
2579
2580This is a [real missing link](missing.md) that should be flagged.
2581"#;
2582
2583 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2584 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2585 let result = rule.check(&ctx).unwrap();
2586
2587 assert_eq!(
2589 result.len(),
2590 1,
2591 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2592 );
2593 assert!(
2594 result[0].message.contains("missing.md"),
2595 "Warning should be for missing.md, not wikilinks"
2596 );
2597 }
2598
2599 #[test]
2600 fn test_wikilinks_not_added_to_index() {
2601 let temp_dir = tempdir().unwrap();
2603 let base_path = temp_dir.path();
2604
2605 let content = r#"# Test Document
2606
2607[[Microsoft#Windows OS]]
2608[[SomePage#section]]
2609[Regular Link](other.md)
2610"#;
2611
2612 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2613 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614
2615 let mut file_index = FileIndex::new();
2616 rule.contribute_to_index(&ctx, &mut file_index);
2617
2618 let cross_file_links = &file_index.cross_file_links;
2621 assert_eq!(
2622 cross_file_links.len(),
2623 1,
2624 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2625 );
2626 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2627 }
2628
2629 #[test]
2630 fn test_reference_definition_missing_file() {
2631 let temp_dir = tempdir().unwrap();
2633 let base_path = temp_dir.path();
2634
2635 let content = r#"# Test Document
2636
2637[test]: ./missing.md
2638[example]: ./nonexistent.html
2639
2640Use [test] and [example] here.
2641"#;
2642
2643 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2644 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2645 let result = rule.check(&ctx).unwrap();
2646
2647 assert_eq!(
2649 result.len(),
2650 2,
2651 "Should have warnings for missing reference definition targets. Got: {result:?}"
2652 );
2653 assert!(
2654 result.iter().any(|w| w.message.contains("missing.md")),
2655 "Should warn about missing.md"
2656 );
2657 assert!(
2658 result.iter().any(|w| w.message.contains("nonexistent.html")),
2659 "Should warn about nonexistent.html"
2660 );
2661 }
2662
2663 #[test]
2664 fn test_reference_definition_existing_file() {
2665 let temp_dir = tempdir().unwrap();
2667 let base_path = temp_dir.path();
2668
2669 let exists_path = base_path.join("exists.md");
2671 File::create(&exists_path)
2672 .unwrap()
2673 .write_all(b"# Existing file")
2674 .unwrap();
2675
2676 let content = r#"# Test Document
2677
2678[test]: ./exists.md
2679
2680Use [test] here.
2681"#;
2682
2683 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2684 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2685 let result = rule.check(&ctx).unwrap();
2686
2687 assert!(
2689 result.is_empty(),
2690 "Should not warn about existing file. Got: {result:?}"
2691 );
2692 }
2693
2694 #[test]
2695 fn test_reference_definition_external_url_skipped() {
2696 let temp_dir = tempdir().unwrap();
2698 let base_path = temp_dir.path();
2699
2700 let content = r#"# Test Document
2701
2702[google]: https://google.com
2703[example]: http://example.org
2704[mail]: mailto:test@example.com
2705[ftp]: ftp://files.example.com
2706[local]: ./missing.md
2707
2708Use [google], [example], [mail], [ftp], [local] here.
2709"#;
2710
2711 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2712 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2713 let result = rule.check(&ctx).unwrap();
2714
2715 assert_eq!(
2717 result.len(),
2718 1,
2719 "Should only warn about local missing file. Got: {result:?}"
2720 );
2721 assert!(
2722 result[0].message.contains("missing.md"),
2723 "Warning should be for missing.md"
2724 );
2725 }
2726
2727 #[test]
2728 fn test_reference_definition_fragment_only_skipped() {
2729 let temp_dir = tempdir().unwrap();
2731 let base_path = temp_dir.path();
2732
2733 let content = r#"# Test Document
2734
2735[section]: #my-section
2736
2737Use [section] here.
2738"#;
2739
2740 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2742 let result = rule.check(&ctx).unwrap();
2743
2744 assert!(
2746 result.is_empty(),
2747 "Should not warn about fragment-only reference. Got: {result:?}"
2748 );
2749 }
2750
2751 #[test]
2752 fn test_reference_definition_column_position() {
2753 let temp_dir = tempdir().unwrap();
2755 let base_path = temp_dir.path();
2756
2757 let content = "[ref]: ./missing.md";
2760 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2764 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2765 let result = rule.check(&ctx).unwrap();
2766
2767 assert_eq!(result.len(), 1, "Should have exactly one warning");
2768 assert_eq!(result[0].line, 1, "Should be on line 1");
2769 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2770 }
2771
2772 #[test]
2773 fn test_reference_definition_html_with_md_source() {
2774 let temp_dir = tempdir().unwrap();
2776 let base_path = temp_dir.path();
2777
2778 let md_file = base_path.join("guide.md");
2780 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2781
2782 let content = r#"# Test Document
2783
2784[guide]: ./guide.html
2785[missing]: ./missing.html
2786
2787Use [guide] and [missing] here.
2788"#;
2789
2790 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2791 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2792 let result = rule.check(&ctx).unwrap();
2793
2794 assert_eq!(
2796 result.len(),
2797 1,
2798 "Should only warn about missing source. Got: {result:?}"
2799 );
2800 assert!(result[0].message.contains("missing.html"));
2801 }
2802
2803 #[test]
2804 fn test_reference_definition_url_encoded() {
2805 let temp_dir = tempdir().unwrap();
2807 let base_path = temp_dir.path();
2808
2809 let file_with_spaces = base_path.join("file with spaces.md");
2811 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2812
2813 let content = r#"# Test Document
2814
2815[spaces]: ./file%20with%20spaces.md
2816[missing]: ./missing%20file.md
2817
2818Use [spaces] and [missing] here.
2819"#;
2820
2821 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2822 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2823 let result = rule.check(&ctx).unwrap();
2824
2825 assert_eq!(
2827 result.len(),
2828 1,
2829 "Should only warn about missing URL-encoded file. Got: {result:?}"
2830 );
2831 assert!(result[0].message.contains("missing%20file.md"));
2832 }
2833
2834 #[test]
2835 fn test_inline_and_reference_both_checked() {
2836 let temp_dir = tempdir().unwrap();
2838 let base_path = temp_dir.path();
2839
2840 let content = r#"# Test Document
2841
2842[inline link](./inline-missing.md)
2843[ref]: ./ref-missing.md
2844
2845Use [ref] here.
2846"#;
2847
2848 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2849 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2850 let result = rule.check(&ctx).unwrap();
2851
2852 assert_eq!(
2854 result.len(),
2855 2,
2856 "Should warn about both inline and reference links. Got: {result:?}"
2857 );
2858 assert!(
2859 result.iter().any(|w| w.message.contains("inline-missing.md")),
2860 "Should warn about inline-missing.md"
2861 );
2862 assert!(
2863 result.iter().any(|w| w.message.contains("ref-missing.md")),
2864 "Should warn about ref-missing.md"
2865 );
2866 }
2867
2868 #[test]
2869 fn test_footnote_definitions_not_flagged() {
2870 let rule = MD057ExistingRelativeLinks::default();
2873
2874 let content = r#"# Title
2875
2876A footnote[^1].
2877
2878[^1]: [link](https://www.google.com).
2879"#;
2880
2881 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2882 let result = rule.check(&ctx).unwrap();
2883
2884 assert!(
2885 result.is_empty(),
2886 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2887 );
2888 }
2889
2890 #[test]
2891 fn test_footnote_with_relative_link_inside() {
2892 let rule = MD057ExistingRelativeLinks::default();
2895
2896 let content = r#"# Title
2897
2898See the footnote[^1].
2899
2900[^1]: Check out [this file](./existing.md) for more info.
2901[^2]: Also see [missing](./does-not-exist.md).
2902"#;
2903
2904 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2905 let result = rule.check(&ctx).unwrap();
2906
2907 for warning in &result {
2912 assert!(
2913 !warning.message.contains("[this file]"),
2914 "Footnote content should not be treated as URL: {warning:?}"
2915 );
2916 assert!(
2917 !warning.message.contains("[missing]"),
2918 "Footnote content should not be treated as URL: {warning:?}"
2919 );
2920 }
2921 }
2922
2923 #[test]
2924 fn test_mixed_footnotes_and_reference_definitions() {
2925 let temp_dir = tempdir().unwrap();
2927 let base_path = temp_dir.path();
2928
2929 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2930
2931 let content = r#"# Title
2932
2933A footnote[^1] and a [ref link][myref].
2934
2935[^1]: This is a footnote with [link](https://example.com).
2936
2937[myref]: ./missing-file.md "This should be checked"
2938"#;
2939
2940 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2941 let result = rule.check(&ctx).unwrap();
2942
2943 assert_eq!(
2945 result.len(),
2946 1,
2947 "Should only warn about the regular reference definition. Got: {result:?}"
2948 );
2949 assert!(
2950 result[0].message.contains("missing-file.md"),
2951 "Should warn about missing-file.md in reference definition"
2952 );
2953 }
2954
2955 #[test]
2956 fn test_absolute_links_ignore_by_default() {
2957 let temp_dir = tempdir().unwrap();
2959 let base_path = temp_dir.path();
2960
2961 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2962
2963 let content = r#"# Links
2964
2965[API docs](/api/v1/users)
2966[Blog post](/blog/2024/release.html)
2967
2968
2969[ref]: /docs/reference.md
2970"#;
2971
2972 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2973 let result = rule.check(&ctx).unwrap();
2974
2975 assert!(
2977 result.is_empty(),
2978 "Absolute links should be ignored by default. Got: {result:?}"
2979 );
2980 }
2981
2982 #[test]
2983 fn test_absolute_links_warn_config() {
2984 let temp_dir = tempdir().unwrap();
2986 let base_path = temp_dir.path();
2987
2988 let config = MD057Config {
2989 absolute_links: AbsoluteLinksOption::Warn,
2990 ..Default::default()
2991 };
2992 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
2993
2994 let content = r#"# Links
2995
2996[API docs](/api/v1/users)
2997[Blog post](/blog/2024/release.html)
2998"#;
2999
3000 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3001 let result = rule.check(&ctx).unwrap();
3002
3003 assert_eq!(
3005 result.len(),
3006 2,
3007 "Should warn about both absolute links. Got: {result:?}"
3008 );
3009 assert!(
3010 result[0].message.contains("cannot be validated locally"),
3011 "Warning should explain why: {}",
3012 result[0].message
3013 );
3014 assert!(
3015 result[0].message.contains("/api/v1/users"),
3016 "Warning should include the link path"
3017 );
3018 }
3019
3020 #[test]
3021 fn test_absolute_links_warn_images() {
3022 let temp_dir = tempdir().unwrap();
3024 let base_path = temp_dir.path();
3025
3026 let config = MD057Config {
3027 absolute_links: AbsoluteLinksOption::Warn,
3028 ..Default::default()
3029 };
3030 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3031
3032 let content = r#"# Images
3033
3034
3035"#;
3036
3037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3038 let result = rule.check(&ctx).unwrap();
3039
3040 assert_eq!(
3041 result.len(),
3042 1,
3043 "Should warn about absolute image path. Got: {result:?}"
3044 );
3045 assert!(
3046 result[0].message.contains("/assets/logo.png"),
3047 "Warning should include the image path"
3048 );
3049 }
3050
3051 #[test]
3052 fn test_absolute_links_warn_reference_definitions() {
3053 let temp_dir = tempdir().unwrap();
3055 let base_path = temp_dir.path();
3056
3057 let config = MD057Config {
3058 absolute_links: AbsoluteLinksOption::Warn,
3059 ..Default::default()
3060 };
3061 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3062
3063 let content = r#"# Reference
3064
3065See the [docs][ref].
3066
3067[ref]: /docs/reference.md
3068"#;
3069
3070 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3071 let result = rule.check(&ctx).unwrap();
3072
3073 assert_eq!(
3074 result.len(),
3075 1,
3076 "Should warn about absolute reference definition. Got: {result:?}"
3077 );
3078 assert!(
3079 result[0].message.contains("/docs/reference.md"),
3080 "Warning should include the reference path"
3081 );
3082 }
3083
3084 #[test]
3085 fn test_search_paths_inline_link() {
3086 let temp_dir = tempdir().unwrap();
3087 let base_path = temp_dir.path();
3088
3089 let assets_dir = base_path.join("assets");
3091 std::fs::create_dir_all(&assets_dir).unwrap();
3092 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3093
3094 let config = MD057Config {
3095 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3096 ..Default::default()
3097 };
3098 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3099
3100 let content = "# Test\n\n[Photo](photo.png)\n";
3101 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3102 let result = rule.check(&ctx).unwrap();
3103
3104 assert!(
3105 result.is_empty(),
3106 "Should find photo.png via search-paths. Got: {result:?}"
3107 );
3108 }
3109
3110 #[test]
3111 fn test_search_paths_image() {
3112 let temp_dir = tempdir().unwrap();
3113 let base_path = temp_dir.path();
3114
3115 let assets_dir = base_path.join("attachments");
3116 std::fs::create_dir_all(&assets_dir).unwrap();
3117 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3118
3119 let config = MD057Config {
3120 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3121 ..Default::default()
3122 };
3123 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3124
3125 let content = "# Test\n\n\n";
3126 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3127 let result = rule.check(&ctx).unwrap();
3128
3129 assert!(
3130 result.is_empty(),
3131 "Should find diagram.svg via search-paths. Got: {result:?}"
3132 );
3133 }
3134
3135 #[test]
3136 fn test_search_paths_reference_definition() {
3137 let temp_dir = tempdir().unwrap();
3138 let base_path = temp_dir.path();
3139
3140 let assets_dir = base_path.join("images");
3141 std::fs::create_dir_all(&assets_dir).unwrap();
3142 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3143
3144 let config = MD057Config {
3145 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3146 ..Default::default()
3147 };
3148 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3149
3150 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3151 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3152 let result = rule.check(&ctx).unwrap();
3153
3154 assert!(
3155 result.is_empty(),
3156 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3157 );
3158 }
3159
3160 #[test]
3161 fn test_search_paths_still_warns_when_truly_missing() {
3162 let temp_dir = tempdir().unwrap();
3163 let base_path = temp_dir.path();
3164
3165 let assets_dir = base_path.join("assets");
3166 std::fs::create_dir_all(&assets_dir).unwrap();
3167
3168 let config = MD057Config {
3169 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3170 ..Default::default()
3171 };
3172 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3173
3174 let content = "# Test\n\n\n";
3175 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176 let result = rule.check(&ctx).unwrap();
3177
3178 assert_eq!(
3179 result.len(),
3180 1,
3181 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3182 );
3183 }
3184
3185 #[test]
3186 fn test_search_paths_nonexistent_directory() {
3187 let temp_dir = tempdir().unwrap();
3188 let base_path = temp_dir.path();
3189
3190 let config = MD057Config {
3191 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3192 ..Default::default()
3193 };
3194 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3195
3196 let content = "# Test\n\n\n";
3197 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3198 let result = rule.check(&ctx).unwrap();
3199
3200 assert_eq!(
3201 result.len(),
3202 1,
3203 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3204 );
3205 }
3206
3207 #[test]
3208 fn test_obsidian_attachment_folder_named() {
3209 let temp_dir = tempdir().unwrap();
3210 let vault = temp_dir.path().join("vault");
3211 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3212 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3213 std::fs::create_dir_all(vault.join("notes")).unwrap();
3214
3215 std::fs::write(
3216 vault.join(".obsidian/app.json"),
3217 r#"{"attachmentFolderPath": "Attachments"}"#,
3218 )
3219 .unwrap();
3220 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3221
3222 let notes_dir = vault.join("notes");
3223 let source_file = notes_dir.join("test.md");
3224 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3225
3226 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3227
3228 let content = "# Test\n\n\n";
3229 let ctx =
3230 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3231 let result = rule.check(&ctx).unwrap();
3232
3233 assert!(
3234 result.is_empty(),
3235 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3236 );
3237 }
3238
3239 #[test]
3240 fn test_obsidian_attachment_same_folder_as_file() {
3241 let temp_dir = tempdir().unwrap();
3242 let vault = temp_dir.path().join("vault-rf");
3243 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3244 std::fs::create_dir_all(vault.join("notes")).unwrap();
3245
3246 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3247
3248 let notes_dir = vault.join("notes");
3250 let source_file = notes_dir.join("test.md");
3251 std::fs::write(&source_file, "placeholder").unwrap();
3252 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3253
3254 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3255
3256 let content = "# Test\n\n\n";
3257 let ctx =
3258 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3259 let result = rule.check(&ctx).unwrap();
3260
3261 assert!(
3262 result.is_empty(),
3263 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3264 );
3265 }
3266
3267 #[test]
3268 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3269 let temp_dir = tempdir().unwrap();
3270 let vault = temp_dir.path().join("vault-nf");
3271 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3272 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3273 std::fs::create_dir_all(vault.join("notes")).unwrap();
3274
3275 std::fs::write(
3276 vault.join(".obsidian/app.json"),
3277 r#"{"attachmentFolderPath": "Attachments"}"#,
3278 )
3279 .unwrap();
3280 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3281
3282 let notes_dir = vault.join("notes");
3283 let source_file = notes_dir.join("test.md");
3284 std::fs::write(&source_file, "placeholder").unwrap();
3285
3286 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3287
3288 let content = "# Test\n\n\n";
3289 let ctx =
3291 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3292 let result = rule.check(&ctx).unwrap();
3293
3294 assert_eq!(
3295 result.len(),
3296 1,
3297 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3298 );
3299 }
3300
3301 #[test]
3302 fn test_search_paths_combined_with_obsidian() {
3303 let temp_dir = tempdir().unwrap();
3304 let vault = temp_dir.path().join("vault-combo");
3305 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3306 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3307 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3308 std::fs::create_dir_all(vault.join("notes")).unwrap();
3309
3310 std::fs::write(
3311 vault.join(".obsidian/app.json"),
3312 r#"{"attachmentFolderPath": "Attachments"}"#,
3313 )
3314 .unwrap();
3315 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3316 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3317
3318 let notes_dir = vault.join("notes");
3319 let source_file = notes_dir.join("test.md");
3320 std::fs::write(&source_file, "placeholder").unwrap();
3321
3322 let extra_assets_dir = vault.join("extra-assets");
3323 let config = MD057Config {
3324 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3325 ..Default::default()
3326 };
3327 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3328
3329 let content = "# Test\n\n\n\n\n";
3331 let ctx =
3332 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3333 let result = rule.check(&ctx).unwrap();
3334
3335 assert!(
3336 result.is_empty(),
3337 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3338 );
3339 }
3340
3341 #[test]
3342 fn test_obsidian_attachment_subfolder_under_file() {
3343 let temp_dir = tempdir().unwrap();
3344 let vault = temp_dir.path().join("vault-sub");
3345 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3346 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3347
3348 std::fs::write(
3349 vault.join(".obsidian/app.json"),
3350 r#"{"attachmentFolderPath": "./assets"}"#,
3351 )
3352 .unwrap();
3353 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3354
3355 let notes_dir = vault.join("notes");
3356 let source_file = notes_dir.join("test.md");
3357 std::fs::write(&source_file, "placeholder").unwrap();
3358
3359 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3360
3361 let content = "# Test\n\n\n";
3362 let ctx =
3363 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3364 let result = rule.check(&ctx).unwrap();
3365
3366 assert!(
3367 result.is_empty(),
3368 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3369 );
3370 }
3371
3372 #[test]
3373 fn test_obsidian_attachment_vault_root() {
3374 let temp_dir = tempdir().unwrap();
3375 let vault = temp_dir.path().join("vault-root");
3376 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3377 std::fs::create_dir_all(vault.join("notes")).unwrap();
3378
3379 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3381 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3382
3383 let notes_dir = vault.join("notes");
3384 let source_file = notes_dir.join("test.md");
3385 std::fs::write(&source_file, "placeholder").unwrap();
3386
3387 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3388
3389 let content = "# Test\n\n\n";
3390 let ctx =
3391 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3392 let result = rule.check(&ctx).unwrap();
3393
3394 assert!(
3395 result.is_empty(),
3396 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3397 );
3398 }
3399
3400 #[test]
3401 fn test_search_paths_multiple_directories() {
3402 let temp_dir = tempdir().unwrap();
3403 let base_path = temp_dir.path();
3404
3405 let dir_a = base_path.join("dir-a");
3406 let dir_b = base_path.join("dir-b");
3407 std::fs::create_dir_all(&dir_a).unwrap();
3408 std::fs::create_dir_all(&dir_b).unwrap();
3409 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3410 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3411
3412 let config = MD057Config {
3413 search_paths: vec![
3414 dir_a.to_string_lossy().into_owned(),
3415 dir_b.to_string_lossy().into_owned(),
3416 ],
3417 ..Default::default()
3418 };
3419 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3420
3421 let content = "# Test\n\n\n\n\n";
3422 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3423 let result = rule.check(&ctx).unwrap();
3424
3425 assert!(
3426 result.is_empty(),
3427 "Should find files across multiple search paths. Got: {result:?}"
3428 );
3429 }
3430
3431 #[test]
3432 fn test_cross_file_check_with_search_paths() {
3433 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3434
3435 let temp_dir = tempdir().unwrap();
3436 let base_path = temp_dir.path();
3437
3438 let docs_dir = base_path.join("docs");
3440 std::fs::create_dir_all(&docs_dir).unwrap();
3441 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3442
3443 let config = MD057Config {
3444 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3445 ..Default::default()
3446 };
3447 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3448
3449 let file_path = base_path.join("README.md");
3450 std::fs::write(&file_path, "# Readme\n").unwrap();
3451
3452 let mut file_index = FileIndex::default();
3453 file_index.cross_file_links.push(CrossFileLinkIndex {
3454 target_path: "guide.md".to_string(),
3455 fragment: String::new(),
3456 line: 3,
3457 column: 1,
3458 });
3459
3460 let workspace_index = WorkspaceIndex::new();
3461
3462 let result = rule
3463 .cross_file_check(&file_path, &file_index, &workspace_index)
3464 .unwrap();
3465
3466 assert!(
3467 result.is_empty(),
3468 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3469 );
3470 }
3471
3472 #[test]
3473 fn test_cross_file_check_with_obsidian_flavor() {
3474 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3475
3476 let temp_dir = tempdir().unwrap();
3477 let vault = temp_dir.path().join("vault-xf");
3478 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3479 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3480 std::fs::create_dir_all(vault.join("notes")).unwrap();
3481
3482 std::fs::write(
3483 vault.join(".obsidian/app.json"),
3484 r#"{"attachmentFolderPath": "Attachments"}"#,
3485 )
3486 .unwrap();
3487 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3488
3489 let notes_dir = vault.join("notes");
3490 let file_path = notes_dir.join("test.md");
3491 std::fs::write(&file_path, "placeholder").unwrap();
3492
3493 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3494 .with_path(¬es_dir)
3495 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3496
3497 let mut file_index = FileIndex::default();
3498 file_index.cross_file_links.push(CrossFileLinkIndex {
3499 target_path: "ref.md".to_string(),
3500 fragment: String::new(),
3501 line: 3,
3502 column: 1,
3503 });
3504
3505 let workspace_index = WorkspaceIndex::new();
3506
3507 let result = rule
3508 .cross_file_check(&file_path, &file_index, &workspace_index)
3509 .unwrap();
3510
3511 assert!(
3512 result.is_empty(),
3513 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3514 );
3515 }
3516
3517 #[test]
3518 fn test_check_clears_stale_cache() {
3519 let temp_dir = tempdir().unwrap();
3522 let base_path = temp_dir.path();
3523
3524 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3525
3526 let phantom_path = base_path.join("phantom.md");
3528 {
3529 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3530 cache.insert(phantom_path.clone(), true);
3531 }
3532
3533 let content = "[phantom](phantom.md)\n";
3534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3535 let warnings = rule.check(&ctx).unwrap();
3536
3537 assert_eq!(
3539 warnings.len(),
3540 1,
3541 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3542 );
3543 assert!(warnings[0].message.contains("phantom.md"));
3544 }
3545
3546 #[test]
3547 fn test_check_does_not_carry_over_cache_between_runs() {
3548 let temp_dir = tempdir().unwrap();
3550 let base_path = temp_dir.path();
3551
3552 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3553
3554 let content = "[missing](nonexistent.md)\n";
3555 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3556
3557 let warnings_1 = rule.check(&ctx).unwrap();
3559 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3560
3561 let nonexistent_path = base_path.join("nonexistent.md");
3563 {
3564 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3565 cache.insert(nonexistent_path.clone(), true);
3566 }
3567
3568 let warnings_2 = rule.check(&ctx).unwrap();
3570 assert_eq!(
3571 warnings_2.len(),
3572 1,
3573 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3574 );
3575 }
3576
3577 #[test]
3583 fn test_no_duplicate_warnings_for_broken_relative_link() {
3584 use crate::workspace_index::WorkspaceIndex;
3585
3586 let temp_dir = tempdir().unwrap();
3587 let base_path = temp_dir.path();
3588
3589 let source_file = base_path.join("index.md");
3591 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3592
3593 let content = "[broken](does/not/exist.md)\n";
3594
3595 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3596
3597 let ctx = crate::lint_context::LintContext::new(
3599 content,
3600 crate::config::MarkdownFlavor::Standard,
3601 Some(source_file.clone()),
3602 );
3603 let check_warnings = rule.check(&ctx).unwrap();
3604
3605 let mut file_index = FileIndex::new();
3607 rule.contribute_to_index(&ctx, &mut file_index);
3608 let workspace_index = WorkspaceIndex::new();
3609 let cross_warnings = rule
3610 .cross_file_check(&source_file, &file_index, &workspace_index)
3611 .unwrap();
3612
3613 let total = check_warnings.len() + cross_warnings.len();
3614 assert_eq!(
3615 total, 1,
3616 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3617 check={check_warnings:?}, cross={cross_warnings:?}"
3618 );
3619 }
3620
3621 #[test]
3626 fn test_absolute_dir_link_accepted_relative_to_roots() {
3627 let temp_dir = tempdir().unwrap();
3628 let root = temp_dir.path();
3629
3630 let dir_d = root.join("d");
3632 std::fs::create_dir_all(&dir_d).unwrap();
3633 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3634
3635 let content = "\
3638[absolute dir](/d)\n\
3639[relative dir](d)\n\
3640[absolute file](/d/foo.md)\n\
3641[relative file](d/foo.md)\n";
3642
3643 let config = MD057Config {
3644 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3645 roots: vec![],
3646 ..Default::default()
3647 };
3648 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3649
3650 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3651 let result = rule.check(&ctx).unwrap();
3652
3653 assert!(
3654 result.is_empty(),
3655 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3656 );
3657 }
3658
3659 #[test]
3662 fn test_absolute_trailing_slash_dir_link_requires_index() {
3663 let temp_dir = tempdir().unwrap();
3664 let root = temp_dir.path();
3665
3666 let dir_d = root.join("d");
3668 std::fs::create_dir_all(&dir_d).unwrap();
3669 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3670
3671 let content = "[dir with slash](/d/)\n";
3673
3674 let config = MD057Config {
3675 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3676 roots: vec![],
3677 ..Default::default()
3678 };
3679 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3680
3681 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3682 let result = rule.check(&ctx).unwrap();
3683
3684 assert_eq!(
3685 result.len(),
3686 1,
3687 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3688 );
3689 }
3690
3691 #[test]
3695 fn test_docs_dir_variant_still_enforces_index_md() {
3696 let temp_dir = tempdir().unwrap();
3697 let root = temp_dir.path();
3698
3699 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3701
3702 let docs_dir = root.join("docs");
3704 std::fs::create_dir_all(&docs_dir).unwrap();
3705 let section_dir = docs_dir.join("section");
3706 std::fs::create_dir_all(§ion_dir).unwrap();
3707 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3708
3709 let source_file = docs_dir.join("index.md");
3711 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3712
3713 let config = MD057Config {
3714 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3715 ..Default::default()
3716 };
3717 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3718
3719 let content = "[sec](/section)\n";
3720 let ctx = crate::lint_context::LintContext::new(
3721 content,
3722 crate::config::MarkdownFlavor::Standard,
3723 Some(source_file.clone()),
3724 );
3725 let result = rule.check(&ctx).unwrap();
3726
3727 assert_eq!(
3729 result.len(),
3730 1,
3731 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3732 );
3733 assert!(
3734 result[0].message.contains("index.md") || result[0].message.contains("section"),
3735 "Message should mention the directory or missing index.md: {}",
3736 result[0].message
3737 );
3738 }
3739
3740 #[test]
3746 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3747 let temp_dir = tempdir().unwrap();
3748 let root = temp_dir.path();
3749
3750 let guide_dir = root.join("guide");
3752 std::fs::create_dir_all(&guide_dir).unwrap();
3753 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3754
3755 let content = "[guide with fragment](/guide/#intro)\n";
3757
3758 let config = MD057Config {
3759 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3760 roots: vec![],
3761 ..Default::default()
3762 };
3763 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3764 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3765 let result = rule.check(&ctx).unwrap();
3766
3767 assert_eq!(
3768 result.len(),
3769 1,
3770 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3771 );
3772 }
3773}