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