1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::range_utils::byte_to_char_count;
10use crate::workspace_index::{FileIndex, extract_cross_file_links};
11use regex::Regex;
12use std::collections::HashMap;
13use std::env;
14use std::path::{Path, PathBuf};
15use std::sync::LazyLock;
16use std::sync::{Arc, Mutex};
17
18mod md057_config;
19use crate::rule_config_serde::RuleConfig;
20use crate::utils::mkdocs_config::resolve_docs_dir;
21use crate::utils::obsidian_config::resolve_attachment_folder;
22use crate::utils::project_root::discover_project_root_from;
23pub use md057_config::{AbsoluteLinksOption, MD057Config};
24
25static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
27 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
28
29fn reset_file_existence_cache() {
31 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
32 cache.clear();
33 }
34}
35
36fn file_exists_with_cache(path: &Path) -> bool {
38 match FILE_EXISTENCE_CACHE.lock() {
39 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
40 Err(_) => path.exists(), }
42}
43
44fn file_exists_or_markdown_extension(path: &Path) -> bool {
47 if file_exists_with_cache(path) {
49 return true;
50 }
51
52 if path.extension().is_none() {
54 for ext in MARKDOWN_EXTENSIONS {
55 let path_with_ext = path.with_extension(&ext[1..]);
57 if file_exists_with_cache(&path_with_ext) {
58 return true;
59 }
60 }
61 }
62
63 false
64}
65
66static LINK_START_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"!?\[[^\]]*\]").unwrap());
68
69static URL_EXTRACT_ANGLE_BRACKET_REGEX: LazyLock<Regex> =
73 LazyLock::new(|| Regex::new(r#"\]\(\s*<([^>]+)>(#[^\)\s]*)?\s*(?:"[^"]*")?\s*\)"#).unwrap());
74
75static URL_EXTRACT_REGEX: LazyLock<Regex> =
78 LazyLock::new(|| Regex::new("\\]\\(\\s*([^>\\)\\s#]+)(#[^)\\s]*)?\\s*(?:\"[^\"]*\")?\\s*\\)").unwrap());
79
80static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
84 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
85
86static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
88
89static PROJECT_ROOT: LazyLock<PathBuf> = LazyLock::new(|| discover_project_root_from(&CURRENT_DIR));
95
96#[inline]
99fn hex_digit_to_value(byte: u8) -> Option<u8> {
100 match byte {
101 b'0'..=b'9' => Some(byte - b'0'),
102 b'a'..=b'f' => Some(byte - b'a' + 10),
103 b'A'..=b'F' => Some(byte - b'A' + 10),
104 _ => None,
105 }
106}
107
108const MARKDOWN_EXTENSIONS: &[&str] = &[
110 ".md",
111 ".markdown",
112 ".mdx",
113 ".mkd",
114 ".mkdn",
115 ".mdown",
116 ".mdwn",
117 ".qmd",
118 ".rmd",
119];
120
121#[derive(Debug, Clone)]
123pub struct MD057ExistingRelativeLinks {
124 base_path: Arc<Mutex<Option<PathBuf>>>,
126 config: MD057Config,
128 flavor: crate::config::MarkdownFlavor,
130}
131
132impl Default for MD057ExistingRelativeLinks {
133 fn default() -> Self {
134 Self {
135 base_path: Arc::new(Mutex::new(None)),
136 config: MD057Config::default(),
137 flavor: crate::config::MarkdownFlavor::default(),
138 }
139 }
140}
141
142impl MD057ExistingRelativeLinks {
143 pub fn new() -> Self {
145 Self::default()
146 }
147
148 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
150 let path = path.as_ref();
151 let dir_path = if path.is_file() {
152 path.parent().map(std::path::Path::to_path_buf)
153 } else {
154 Some(path.to_path_buf())
155 };
156
157 if let Ok(mut guard) = self.base_path.lock() {
158 *guard = dir_path;
159 }
160 self
161 }
162
163 pub fn from_config_struct(config: MD057Config) -> Self {
164 Self {
165 base_path: Arc::new(Mutex::new(None)),
166 config,
167 flavor: crate::config::MarkdownFlavor::default(),
168 }
169 }
170
171 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
175 if Path::new(path_str).is_absolute() {
176 PathBuf::from(path_str)
177 } else {
178 project_root.join(path_str)
179 }
180 }
181
182 #[cfg(test)]
184 fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
185 self.flavor = flavor;
186 self
187 }
188
189 #[inline]
201 fn is_external_url(&self, url: &str) -> bool {
202 if url.is_empty() {
203 return false;
204 }
205
206 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
208 return true;
209 }
210
211 if url.starts_with("{{") || url.starts_with("{%") {
214 return true;
215 }
216
217 if url.contains('@') {
220 return true; }
222
223 if !url.contains('/') && url.ends_with(".com") {
233 return true;
234 }
235
236 if url.starts_with('~') || url.starts_with('@') {
240 return true;
241 }
242
243 false
245 }
246
247 #[inline]
249 fn is_fragment_only_link(&self, url: &str) -> bool {
250 url.starts_with('#')
251 }
252
253 #[inline]
256 fn is_absolute_path(url: &str) -> bool {
257 url.starts_with('/')
258 }
259
260 fn url_decode(path: &str) -> String {
264 if !path.contains('%') {
266 return path.to_string();
267 }
268
269 let bytes = path.as_bytes();
270 let mut result = Vec::with_capacity(bytes.len());
271 let mut i = 0;
272
273 while i < bytes.len() {
274 if bytes[i] == b'%' && i + 2 < bytes.len() {
275 let hex1 = bytes[i + 1];
277 let hex2 = bytes[i + 2];
278 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
279 result.push(d1 * 16 + d2);
280 i += 3;
281 continue;
282 }
283 }
284 result.push(bytes[i]);
285 i += 1;
286 }
287
288 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
290 }
291
292 fn strip_query_and_fragment(url: &str) -> &str {
300 let query_pos = url.find('?');
303 let fragment_pos = url.find('#');
304
305 match (query_pos, fragment_pos) {
306 (Some(q), Some(f)) => {
307 &url[..q.min(f)]
309 }
310 (Some(q), None) => &url[..q],
311 (None, Some(f)) => &url[..f],
312 (None, None) => url,
313 }
314 }
315
316 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
318 base_path.join(link)
319 }
320
321 fn compute_search_paths(
326 &self,
327 flavor: crate::config::MarkdownFlavor,
328 source_file: Option<&Path>,
329 base_path: &Path,
330 project_root: &Path,
331 ) -> Vec<PathBuf> {
332 let mut paths = Vec::new();
333
334 if flavor == crate::config::MarkdownFlavor::Obsidian
336 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
337 && attachment_dir != *base_path
338 {
339 paths.push(attachment_dir);
340 }
341
342 for search_path in &self.config.search_paths {
346 let resolved = Self::resolve_against_project_root(search_path, project_root);
347 if resolved != *base_path && !paths.contains(&resolved) {
348 paths.push(resolved);
349 }
350 }
351
352 paths
353 }
354
355 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
357 search_paths.iter().any(|dir| {
358 let candidate = dir.join(decoded_path);
359 file_exists_or_markdown_extension(&candidate)
360 })
361 }
362
363 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
369 if !self.config.compact_paths {
370 return None;
371 }
372
373 let path_end = url
375 .find('?')
376 .unwrap_or(url.len())
377 .min(url.find('#').unwrap_or(url.len()));
378 let path_part = &url[..path_end];
379 let suffix = &url[path_end..];
380
381 let decoded_path = Self::url_decode(path_part);
383
384 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
385 }
386
387 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
393 let Some(docs_dir) = resolve_docs_dir(source_path) else {
394 return Some(format!(
395 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
396 ));
397 };
398
399 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
400
401 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
404 Resolution::Found => None,
405 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
406 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
407 resolved.display()
408 )),
409 Resolution::NotFound { resolved } => Some(format!(
410 "Absolute link '{url}' resolves to '{}' which does not exist",
411 resolved.display()
412 )),
413 }
414 }
415
416 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
425 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
426
427 for root in roots {
428 let root_path = Self::resolve_against_project_root(root, project_root);
429 if matches!(
432 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
433 Resolution::Found
434 ) {
435 return None;
436 }
437 }
438
439 if matches!(
440 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
442 Resolution::Found
443 ) {
444 return None;
445 }
446
447 let msg = if roots.is_empty() {
448 format!("Absolute link '{url}' was not found under the project root")
449 } else {
450 format!("Absolute link '{url}' was not found under any configured root or the project root")
451 };
452 Some(msg)
453 }
454
455 fn prepare_absolute_url(url: &str) -> (String, bool) {
459 let relative_url = url.trim_start_matches('/');
460 let file_path = Self::strip_query_and_fragment(relative_url);
461 let decoded = Self::url_decode(file_path);
462 let is_directory_link = url.ends_with('/') || decoded.is_empty();
463 (decoded, is_directory_link)
464 }
465
466 fn resolve_under_root_with_opts(
488 root_path: &Path,
489 decoded: &str,
490 is_directory_link: bool,
491 require_index_for_dirs: bool,
492 ) -> Resolution {
493 let resolved = root_path.join(decoded);
494
495 let is_dir = resolved.is_dir();
496
497 if is_directory_link || (require_index_for_dirs && is_dir) {
502 let index_path = resolved.join("index.md");
503 if file_exists_with_cache(&index_path) {
504 return Resolution::Found;
505 }
506 if is_dir {
507 return Resolution::DirectoryWithoutIndex { resolved };
508 }
509 }
510
511 let decoded_has_trailing_slash = decoded.ends_with('/');
517 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
518 return Resolution::Found;
519 }
520
521 if file_exists_or_markdown_extension(&resolved) {
522 return Resolution::Found;
523 }
524
525 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
528 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
529 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
530 {
531 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
532 let source_path = parent.join(format!("{stem}{md_ext}"));
533 file_exists_with_cache(&source_path)
534 });
535 if has_md_source {
536 return Resolution::Found;
537 }
538 }
539
540 Resolution::NotFound { resolved }
541 }
542}
543
544enum Resolution {
548 Found,
549 DirectoryWithoutIndex { resolved: PathBuf },
550 NotFound { resolved: PathBuf },
551}
552
553impl Rule for MD057ExistingRelativeLinks {
554 fn name(&self) -> &'static str {
555 "MD057"
556 }
557
558 fn description(&self) -> &'static str {
559 "Relative links should point to existing files"
560 }
561
562 fn category(&self) -> RuleCategory {
563 RuleCategory::Link
564 }
565
566 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
567 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
568 }
569
570 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
571 let content = ctx.content;
572
573 if content.is_empty() || !content.contains('[') {
575 return Ok(Vec::new());
576 }
577
578 if !content.contains("](") && !content.contains("]:") {
581 return Ok(Vec::new());
582 }
583
584 reset_file_existence_cache();
586
587 let mut warnings = Vec::new();
588
589 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
593
594 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
598
599 let base_path: Option<PathBuf> = {
603 if explicit_base.is_some() {
604 explicit_base
605 } else if let Some(ref source_file) = ctx.source_file {
606 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
610 resolved_file
611 .parent()
612 .map(std::path::Path::to_path_buf)
613 .or_else(|| Some(CURRENT_DIR.clone()))
614 } else {
615 None
617 }
618 };
619
620 let Some(base_path) = base_path else {
622 return Ok(warnings);
623 };
624
625 let extra_search_paths =
627 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
628
629 if !ctx.links.is_empty() {
631 let line_index = &ctx.line_index;
633
634 let lines = ctx.raw_lines();
636
637 let mut processed_lines = std::collections::HashSet::new();
640
641 for link in &ctx.links {
642 let line_idx = link.line - 1;
643 if line_idx >= lines.len() {
644 continue;
645 }
646
647 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
649 continue;
650 }
651
652 if !processed_lines.insert(line_idx) {
654 continue;
655 }
656
657 let line = lines[line_idx];
658
659 if !line.contains("](") {
661 continue;
662 }
663
664 for link_match in LINK_START_REGEX.find_iter(line) {
666 let start_pos = link_match.start();
667 let end_pos = link_match.end();
668
669 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
671 let absolute_start_pos = line_start_byte + start_pos;
672
673 if ctx.is_in_code_span_byte(absolute_start_pos) {
675 continue;
676 }
677
678 if ctx.is_in_math_span(absolute_start_pos) {
680 continue;
681 }
682
683 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
687 .captures_at(line, end_pos - 1)
688 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
689 .or_else(|| {
690 URL_EXTRACT_REGEX
691 .captures_at(line, end_pos - 1)
692 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
693 });
694
695 if let Some((caps, url_group)) = caps_and_url {
696 let url = url_group.as_str().trim();
697
698 if url.is_empty() {
700 continue;
701 }
702
703 if url.starts_with('`') && url.ends_with('`') {
707 continue;
708 }
709
710 if self.is_external_url(url) || self.is_fragment_only_link(url) {
712 continue;
713 }
714
715 if Self::is_absolute_path(url) {
717 match self.config.absolute_links {
718 AbsoluteLinksOption::Warn => {
719 let url_start = url_group.start();
720 let url_end = url_group.end();
721 warnings.push(LintWarning {
722 rule_name: Some(self.name().to_string()),
723 line: link.line,
724 column: byte_to_char_count(line, url_start),
725 end_line: link.line,
726 end_column: byte_to_char_count(line, url_end),
727 message: format!("Absolute link '{url}' cannot be validated locally"),
728 severity: Severity::Warning,
729 fix: None,
730 });
731 }
732 AbsoluteLinksOption::RelativeToDocs => {
733 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
734 let url_start = url_group.start();
735 let url_end = url_group.end();
736 warnings.push(LintWarning {
737 rule_name: Some(self.name().to_string()),
738 line: link.line,
739 column: byte_to_char_count(line, url_start),
740 end_line: link.line,
741 end_column: byte_to_char_count(line, url_end),
742 message: msg,
743 severity: Severity::Warning,
744 fix: None,
745 });
746 }
747 }
748 AbsoluteLinksOption::RelativeToRoots => {
749 if let Some(msg) =
750 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
751 {
752 let url_start = url_group.start();
753 let url_end = url_group.end();
754 warnings.push(LintWarning {
755 rule_name: Some(self.name().to_string()),
756 line: link.line,
757 column: byte_to_char_count(line, url_start),
758 end_line: link.line,
759 end_column: byte_to_char_count(line, url_end),
760 message: msg,
761 severity: Severity::Warning,
762 fix: None,
763 });
764 }
765 }
766 AbsoluteLinksOption::Ignore => {}
767 }
768 continue;
769 }
770
771 let full_url_for_compact = if let Some(frag) = caps.get(2) {
775 format!("{url}{}", frag.as_str())
776 } else {
777 url.to_string()
778 };
779 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
780 let url_start = url_group.start();
781 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
782 let fix_byte_start = line_start_byte + url_start;
783 let fix_byte_end = line_start_byte + url_end;
784 warnings.push(LintWarning {
785 rule_name: Some(self.name().to_string()),
786 line: link.line,
787 column: byte_to_char_count(line, url_start),
788 end_line: link.line,
789 end_column: byte_to_char_count(line, url_end),
790 message: format!(
791 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
792 ),
793 severity: Severity::Warning,
794 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
795 });
796 }
797
798 let file_path = Self::strip_query_and_fragment(url);
800
801 let decoded_path = Self::url_decode(file_path);
803
804 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
806
807 if file_exists_or_markdown_extension(&resolved_path) {
809 continue; }
811
812 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
814 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
815 && let (Some(stem), Some(parent)) = (
816 resolved_path.file_stem().and_then(|s| s.to_str()),
817 resolved_path.parent(),
818 ) {
819 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
820 let source_path = parent.join(format!("{stem}{md_ext}"));
821 file_exists_with_cache(&source_path)
822 })
823 } else {
824 false
825 };
826
827 if has_md_source {
828 continue; }
830
831 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
833 continue;
834 }
835
836 let url_start = url_group.start();
840 let url_end = url_group.end();
841
842 warnings.push(LintWarning {
843 rule_name: Some(self.name().to_string()),
844 line: link.line,
845 column: byte_to_char_count(line, url_start),
846 end_line: link.line,
847 end_column: byte_to_char_count(line, url_end),
848 message: format!("Relative link '{url}' does not exist"),
849 severity: Severity::Error,
850 fix: None,
851 });
852 }
853 }
854 }
855 }
856
857 for image in &ctx.images {
859 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
861 continue;
862 }
863
864 let url = image.url.as_ref();
865
866 if url.is_empty() {
868 continue;
869 }
870
871 if self.is_external_url(url) || self.is_fragment_only_link(url) {
873 continue;
874 }
875
876 if Self::is_absolute_path(url) {
878 match self.config.absolute_links {
879 AbsoluteLinksOption::Warn => {
880 warnings.push(LintWarning {
881 rule_name: Some(self.name().to_string()),
882 line: image.line,
883 column: image.start_col + 1,
884 end_line: image.line,
885 end_column: image.start_col + 1 + url.chars().count(),
886 message: format!("Absolute link '{url}' cannot be validated locally"),
887 severity: Severity::Warning,
888 fix: None,
889 });
890 }
891 AbsoluteLinksOption::RelativeToDocs => {
892 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
893 warnings.push(LintWarning {
894 rule_name: Some(self.name().to_string()),
895 line: image.line,
896 column: image.start_col + 1,
897 end_line: image.line,
898 end_column: image.start_col + 1 + url.chars().count(),
899 message: msg,
900 severity: Severity::Warning,
901 fix: None,
902 });
903 }
904 }
905 AbsoluteLinksOption::RelativeToRoots => {
906 if let Some(msg) =
907 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
908 {
909 warnings.push(LintWarning {
910 rule_name: Some(self.name().to_string()),
911 line: image.line,
912 column: image.start_col + 1,
913 end_line: image.line,
914 end_column: image.start_col + 1 + url.chars().count(),
915 message: msg,
916 severity: Severity::Warning,
917 fix: None,
918 });
919 }
920 }
921 AbsoluteLinksOption::Ignore => {}
922 }
923 continue;
924 }
925
926 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
928 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
931 let fix_byte_start = image.byte_offset + url_offset;
932 let fix_byte_end = fix_byte_start + url.len();
933 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
934 });
935
936 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
937 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
938 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
941 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
942 });
943 warnings.push(LintWarning {
944 rule_name: Some(self.name().to_string()),
945 line: image.line,
946 column: url_col,
947 end_line: image.line,
948 end_column: url_col + url.chars().count(),
949 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
950 severity: Severity::Warning,
951 fix,
952 });
953 }
954
955 let file_path = Self::strip_query_and_fragment(url);
957
958 let decoded_path = Self::url_decode(file_path);
960
961 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
963
964 if file_exists_or_markdown_extension(&resolved_path) {
966 continue; }
968
969 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
971 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
972 && let (Some(stem), Some(parent)) = (
973 resolved_path.file_stem().and_then(|s| s.to_str()),
974 resolved_path.parent(),
975 ) {
976 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
977 let source_path = parent.join(format!("{stem}{md_ext}"));
978 file_exists_with_cache(&source_path)
979 })
980 } else {
981 false
982 };
983
984 if has_md_source {
985 continue; }
987
988 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
990 continue;
991 }
992
993 warnings.push(LintWarning {
996 rule_name: Some(self.name().to_string()),
997 line: image.line,
998 column: image.start_col + 1,
999 end_line: image.line,
1000 end_column: image.start_col + 1 + url.chars().count(),
1001 message: format!("Relative link '{url}' does not exist"),
1002 severity: Severity::Error,
1003 fix: None,
1004 });
1005 }
1006
1007 for ref_def in &ctx.reference_defs {
1009 let url = &ref_def.url;
1010
1011 if url.is_empty() {
1013 continue;
1014 }
1015
1016 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1018 continue;
1019 }
1020
1021 if Self::is_absolute_path(url) {
1023 match self.config.absolute_links {
1024 AbsoluteLinksOption::Warn => {
1025 let line_idx = ref_def.line - 1;
1026 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1027 line_content
1028 .find(url.as_str())
1029 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1030 });
1031 warnings.push(LintWarning {
1032 rule_name: Some(self.name().to_string()),
1033 line: ref_def.line,
1034 column,
1035 end_line: ref_def.line,
1036 end_column: column + url.chars().count(),
1037 message: format!("Absolute link '{url}' cannot be validated locally"),
1038 severity: Severity::Warning,
1039 fix: None,
1040 });
1041 }
1042 AbsoluteLinksOption::RelativeToDocs => {
1043 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
1044 let line_idx = ref_def.line - 1;
1045 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1046 line_content
1047 .find(url.as_str())
1048 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1049 });
1050 warnings.push(LintWarning {
1051 rule_name: Some(self.name().to_string()),
1052 line: ref_def.line,
1053 column,
1054 end_line: ref_def.line,
1055 end_column: column + url.chars().count(),
1056 message: msg,
1057 severity: Severity::Warning,
1058 fix: None,
1059 });
1060 }
1061 }
1062 AbsoluteLinksOption::RelativeToRoots => {
1063 if let Some(msg) =
1064 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
1065 {
1066 let line_idx = ref_def.line - 1;
1067 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1068 line_content
1069 .find(url.as_str())
1070 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1071 });
1072 warnings.push(LintWarning {
1073 rule_name: Some(self.name().to_string()),
1074 line: ref_def.line,
1075 column,
1076 end_line: ref_def.line,
1077 end_column: column + url.chars().count(),
1078 message: msg,
1079 severity: Severity::Warning,
1080 fix: None,
1081 });
1082 }
1083 }
1084 AbsoluteLinksOption::Ignore => {}
1085 }
1086 continue;
1087 }
1088
1089 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1091 let ref_line_idx = ref_def.line - 1;
1092 let line_content = ctx.raw_lines().get(ref_line_idx).copied().unwrap_or("");
1093 let url_byte = line_content.find(url.as_str());
1096 let col = url_byte.map_or(1, |b| byte_to_char_count(line_content, b));
1097 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1098 let fix_byte_start = ref_line_start_byte + url_byte.unwrap_or(0);
1099 let fix_byte_end = fix_byte_start + url.len();
1100 warnings.push(LintWarning {
1101 rule_name: Some(self.name().to_string()),
1102 line: ref_def.line,
1103 column: col,
1104 end_line: ref_def.line,
1105 end_column: col + url.chars().count(),
1106 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1107 severity: Severity::Warning,
1108 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1109 });
1110 }
1111
1112 let file_path = Self::strip_query_and_fragment(url);
1114
1115 let decoded_path = Self::url_decode(file_path);
1117
1118 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1120
1121 if file_exists_or_markdown_extension(&resolved_path) {
1123 continue; }
1125
1126 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1128 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1129 && let (Some(stem), Some(parent)) = (
1130 resolved_path.file_stem().and_then(|s| s.to_str()),
1131 resolved_path.parent(),
1132 ) {
1133 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1134 let source_path = parent.join(format!("{stem}{md_ext}"));
1135 file_exists_with_cache(&source_path)
1136 })
1137 } else {
1138 false
1139 };
1140
1141 if has_md_source {
1142 continue; }
1144
1145 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1147 continue;
1148 }
1149
1150 let line_idx = ref_def.line - 1;
1153 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1154 line_content
1156 .find(url.as_str())
1157 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1158 });
1159
1160 warnings.push(LintWarning {
1161 rule_name: Some(self.name().to_string()),
1162 line: ref_def.line,
1163 column,
1164 end_line: ref_def.line,
1165 end_column: column + url.chars().count(),
1166 message: format!("Relative link '{url}' does not exist"),
1167 severity: Severity::Error,
1168 fix: None,
1169 });
1170 }
1171
1172 Ok(warnings)
1173 }
1174
1175 fn fix_capability(&self) -> FixCapability {
1176 if self.config.compact_paths {
1177 FixCapability::ConditionallyFixable
1178 } else {
1179 FixCapability::Unfixable
1180 }
1181 }
1182
1183 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1184 if !self.config.compact_paths {
1185 return Ok(ctx.content.to_string());
1186 }
1187
1188 let warnings = self.check(ctx)?;
1189 let warnings =
1190 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1191 let mut content = ctx.content.to_string();
1192
1193 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1195 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1196
1197 for fix in fixes {
1198 if fix.range.end <= content.len() {
1199 content.replace_range(fix.range.clone(), &fix.replacement);
1200 }
1201 }
1202
1203 Ok(content)
1204 }
1205
1206 fn as_any(&self) -> &dyn std::any::Any {
1207 self
1208 }
1209
1210 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1211 let default_config = MD057Config::default();
1212 let json_value = serde_json::to_value(&default_config).ok()?;
1213 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1214
1215 if let toml::Value::Table(table) = toml_value {
1216 if !table.is_empty() {
1217 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1218 } else {
1219 None
1220 }
1221 } else {
1222 None
1223 }
1224 }
1225
1226 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1227 where
1228 Self: Sized,
1229 {
1230 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1231 let mut rule = Self::from_config_struct(rule_config);
1232 rule.flavor = config.global.flavor;
1233 Box::new(rule)
1234 }
1235
1236 fn cross_file_scope(&self) -> CrossFileScope {
1237 CrossFileScope::Workspace
1238 }
1239
1240 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1241 let links = extract_cross_file_links(ctx);
1244 for link in links.relative {
1245 index.add_cross_file_link(link);
1246 }
1247 for link in links.root_relative {
1250 index.add_root_relative_link(link);
1251 }
1252 }
1253
1254 fn cross_file_check(
1255 &self,
1256 _file_path: &Path,
1257 _file_index: &FileIndex,
1258 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1259 ) -> LintResult {
1260 Ok(Vec::new())
1270 }
1271}
1272
1273fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1278 let from_components: Vec<_> = from_dir.components().collect();
1279 let to_components: Vec<_> = to_path.components().collect();
1280
1281 let common_len = from_components
1283 .iter()
1284 .zip(to_components.iter())
1285 .take_while(|(a, b)| a == b)
1286 .count();
1287
1288 let mut result = PathBuf::new();
1289
1290 for _ in common_len..from_components.len() {
1292 result.push("..");
1293 }
1294
1295 for component in &to_components[common_len..] {
1297 result.push(component);
1298 }
1299
1300 result
1301}
1302
1303fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1309 let link_path = Path::new(raw_link_path);
1310
1311 let has_traversal = link_path
1313 .components()
1314 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1315
1316 if !has_traversal {
1317 return None;
1318 }
1319
1320 let combined = source_dir.join(link_path);
1322 let normalized_target = normalize_path(&combined);
1323
1324 let normalized_source = normalize_path(source_dir);
1326 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1327
1328 if shortest != link_path {
1330 let compact = shortest.to_string_lossy().to_string();
1331 if compact.is_empty() {
1333 return None;
1334 }
1335 Some(compact.replace('\\', "/"))
1337 } else {
1338 None
1339 }
1340}
1341
1342fn normalize_path(path: &Path) -> PathBuf {
1344 let mut components = Vec::new();
1345
1346 for component in path.components() {
1347 match component {
1348 std::path::Component::ParentDir => {
1349 if !components.is_empty() {
1351 components.pop();
1352 }
1353 }
1354 std::path::Component::CurDir => {
1355 }
1357 _ => {
1358 components.push(component);
1359 }
1360 }
1361 }
1362
1363 components.iter().collect()
1364}
1365
1366#[cfg(test)]
1367mod tests {
1368 use super::*;
1369 use crate::workspace_index::CrossFileLinkIndex;
1370 use std::fs::File;
1371 use std::io::Write;
1372 use tempfile::tempdir;
1373
1374 #[test]
1375 fn test_strip_query_and_fragment() {
1376 assert_eq!(
1378 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1379 "file.png"
1380 );
1381 assert_eq!(
1382 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1383 "file.png"
1384 );
1385 assert_eq!(
1386 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1387 "file.png"
1388 );
1389
1390 assert_eq!(
1392 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1393 "file.md"
1394 );
1395 assert_eq!(
1396 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1397 "file.md"
1398 );
1399
1400 assert_eq!(
1402 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1403 "file.md"
1404 );
1405
1406 assert_eq!(
1408 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1409 "file.png"
1410 );
1411
1412 assert_eq!(
1414 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1415 "path/to/image.png"
1416 );
1417 assert_eq!(
1418 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1419 "path/to/image.png"
1420 );
1421
1422 assert_eq!(
1424 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1425 "file.md"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_url_decode() {
1431 assert_eq!(
1433 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1434 "penguin with space.jpg"
1435 );
1436
1437 assert_eq!(
1439 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1440 "assets/my file name.png"
1441 );
1442
1443 assert_eq!(
1445 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1446 "hello world!.md"
1447 );
1448
1449 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1451
1452 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1454
1455 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1457
1458 assert_eq!(
1460 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1461 "normal-file.md"
1462 );
1463
1464 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1466
1467 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1469
1470 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1472
1473 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1475
1476 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1478
1479 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1481
1482 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1484
1485 assert_eq!(
1487 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1488 "path/to/file.md"
1489 );
1490
1491 assert_eq!(
1493 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1494 "hello world/foo bar.md"
1495 );
1496
1497 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1499
1500 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1502 }
1503
1504 #[test]
1505 fn test_url_encoded_filenames() {
1506 let temp_dir = tempdir().unwrap();
1508 let base_path = temp_dir.path();
1509
1510 let file_with_spaces = base_path.join("penguin with space.jpg");
1512 File::create(&file_with_spaces)
1513 .unwrap()
1514 .write_all(b"image data")
1515 .unwrap();
1516
1517 let subdir = base_path.join("my images");
1519 std::fs::create_dir(&subdir).unwrap();
1520 let nested_file = subdir.join("photo 1.png");
1521 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1522
1523 let content = r#"
1525# Test Document with URL-Encoded Links
1526
1527
1528
1529
1530"#;
1531
1532 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1533
1534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1535 let result = rule.check(&ctx).unwrap();
1536
1537 assert_eq!(
1539 result.len(),
1540 1,
1541 "Should only warn about missing%20file.jpg. Got: {result:?}"
1542 );
1543 assert!(
1544 result[0].message.contains("missing%20file.jpg"),
1545 "Warning should mention the URL-encoded filename"
1546 );
1547 }
1548
1549 #[test]
1550 fn test_external_urls() {
1551 let rule = MD057ExistingRelativeLinks::new();
1552
1553 assert!(rule.is_external_url("https://example.com"));
1555 assert!(rule.is_external_url("http://example.com"));
1556 assert!(rule.is_external_url("ftp://example.com"));
1557 assert!(rule.is_external_url("www.example.com"));
1558 assert!(rule.is_external_url("example.com"));
1559
1560 assert!(rule.is_external_url("file:///path/to/file"));
1562 assert!(rule.is_external_url("smb://server/share"));
1563 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1564 assert!(rule.is_external_url("mailto:user@example.com"));
1565 assert!(rule.is_external_url("tel:+1234567890"));
1566 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1567 assert!(rule.is_external_url("javascript:void(0)"));
1568 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1569 assert!(rule.is_external_url("git://github.com/repo.git"));
1570
1571 assert!(rule.is_external_url("user@example.com"));
1574 assert!(rule.is_external_url("steering@kubernetes.io"));
1575 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1576 assert!(rule.is_external_url("user_name@sub.domain.com"));
1577 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1578
1579 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"));
1590 assert!(!rule.is_external_url("/blog/2024/release.html"));
1591 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1592 assert!(!rule.is_external_url("/pkg/runtime"));
1593 assert!(!rule.is_external_url("/doc/go1compat"));
1594 assert!(!rule.is_external_url("/index.html"));
1595 assert!(!rule.is_external_url("/assets/logo.png"));
1596
1597 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1599 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1600 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1601 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1602 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1603
1604 assert!(rule.is_external_url("~/assets/image.png"));
1607 assert!(rule.is_external_url("~/components/Button.vue"));
1608 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1612 assert!(rule.is_external_url("@images/photo.jpg"));
1613 assert!(rule.is_external_url("@assets/styles.css"));
1614
1615 assert!(!rule.is_external_url("./relative/path.md"));
1617 assert!(!rule.is_external_url("relative/path.md"));
1618 assert!(!rule.is_external_url("../parent/path.md"));
1619 }
1620
1621 #[test]
1622 fn test_dot_com_only_skips_bare_domains() {
1623 let rule = MD057ExistingRelativeLinks::new();
1624
1625 assert!(rule.is_external_url("example.com"));
1627 assert!(rule.is_external_url("sub.example.com"));
1628
1629 assert!(!rule.is_external_url("../../vendor.com"));
1633 assert!(!rule.is_external_url("./vendor.com"));
1634 assert!(!rule.is_external_url("docs/vendor.com"));
1635 }
1636
1637 #[test]
1638 fn test_framework_path_aliases() {
1639 let temp_dir = tempdir().unwrap();
1641 let base_path = temp_dir.path();
1642
1643 let content = r#"
1645# Framework Path Aliases
1646
1647
1648
1649
1650
1651[Link](@/pages/about.md)
1652
1653This is a [real missing link](missing.md) that should be flagged.
1654"#;
1655
1656 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1657
1658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1659 let result = rule.check(&ctx).unwrap();
1660
1661 assert_eq!(
1663 result.len(),
1664 1,
1665 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1666 );
1667 assert!(
1668 result[0].message.contains("missing.md"),
1669 "Warning should be for missing.md"
1670 );
1671 }
1672
1673 #[test]
1674 fn test_url_decode_security_path_traversal() {
1675 let temp_dir = tempdir().unwrap();
1678 let base_path = temp_dir.path();
1679
1680 let file_in_base = base_path.join("safe.md");
1682 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1683
1684 let content = r#"
1689[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1690[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1691[Safe link](safe.md)
1692"#;
1693
1694 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1695
1696 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697 let result = rule.check(&ctx).unwrap();
1698
1699 assert_eq!(
1702 result.len(),
1703 2,
1704 "Should have warnings for traversal attempts. Got: {result:?}"
1705 );
1706 }
1707
1708 #[test]
1709 fn test_url_encoded_utf8_filenames() {
1710 let temp_dir = tempdir().unwrap();
1712 let base_path = temp_dir.path();
1713
1714 let cafe_file = base_path.join("café.md");
1716 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1717
1718 let content = r#"
1719[Café link](caf%C3%A9.md)
1720[Missing unicode](r%C3%A9sum%C3%A9.md)
1721"#;
1722
1723 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1724
1725 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1726 let result = rule.check(&ctx).unwrap();
1727
1728 assert_eq!(
1730 result.len(),
1731 1,
1732 "Should only warn about missing résumé.md. Got: {result:?}"
1733 );
1734 assert!(
1735 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1736 "Warning should mention the URL-encoded filename"
1737 );
1738 }
1739
1740 #[test]
1741 fn test_url_encoded_emoji_filenames() {
1742 let temp_dir = tempdir().unwrap();
1745 let base_path = temp_dir.path();
1746
1747 let emoji_dir = base_path.join("👤 Personal");
1749 std::fs::create_dir(&emoji_dir).unwrap();
1750
1751 let file_path = emoji_dir.join("TV Shows.md");
1753 File::create(&file_path)
1754 .unwrap()
1755 .write_all(b"# TV Shows\n\nContent here.")
1756 .unwrap();
1757
1758 let content = r#"
1761# Test Document
1762
1763[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1764[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1765"#;
1766
1767 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1768
1769 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1770 let result = rule.check(&ctx).unwrap();
1771
1772 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1774 assert!(
1775 result[0].message.contains("Missing.md"),
1776 "Warning should be for Missing.md, got: {}",
1777 result[0].message
1778 );
1779 }
1780
1781 #[test]
1782 fn test_no_warnings_without_base_path() {
1783 let rule = MD057ExistingRelativeLinks::new();
1784 let content = "[Link](missing.md)";
1785
1786 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1787 let result = rule.check(&ctx).unwrap();
1788 assert!(result.is_empty(), "Should have no warnings without base path");
1789 }
1790
1791 #[test]
1792 fn test_existing_and_missing_links() {
1793 let temp_dir = tempdir().unwrap();
1795 let base_path = temp_dir.path();
1796
1797 let exists_path = base_path.join("exists.md");
1799 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1800
1801 assert!(exists_path.exists(), "exists.md should exist for this test");
1803
1804 let content = r#"
1806# Test Document
1807
1808[Valid Link](exists.md)
1809[Invalid Link](missing.md)
1810[External Link](https://example.com)
1811[Media Link](image.jpg)
1812 "#;
1813
1814 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1816
1817 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1819 let result = rule.check(&ctx).unwrap();
1820
1821 assert_eq!(result.len(), 2);
1823 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1824 assert!(messages.iter().any(|m| m.contains("missing.md")));
1825 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1826 }
1827
1828 #[test]
1829 fn test_angle_bracket_links() {
1830 let temp_dir = tempdir().unwrap();
1832 let base_path = temp_dir.path();
1833
1834 let exists_path = base_path.join("exists.md");
1836 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1837
1838 let content = r#"
1840# Test Document
1841
1842[Valid Link](<exists.md>)
1843[Invalid Link](<missing.md>)
1844[External Link](<https://example.com>)
1845 "#;
1846
1847 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1849
1850 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1851 let result = rule.check(&ctx).unwrap();
1852
1853 assert_eq!(result.len(), 1, "Should have exactly one warning");
1855 assert!(
1856 result[0].message.contains("missing.md"),
1857 "Warning should mention missing.md"
1858 );
1859 }
1860
1861 #[test]
1862 fn test_angle_bracket_links_with_parens() {
1863 let temp_dir = tempdir().unwrap();
1865 let base_path = temp_dir.path();
1866
1867 let app_dir = base_path.join("app");
1869 std::fs::create_dir(&app_dir).unwrap();
1870 let upload_dir = app_dir.join("(upload)");
1871 std::fs::create_dir(&upload_dir).unwrap();
1872 let page_file = upload_dir.join("page.tsx");
1873 File::create(&page_file)
1874 .unwrap()
1875 .write_all(b"export default function Page() {}")
1876 .unwrap();
1877
1878 let content = r#"
1880# Test Document with Paths Containing Parens
1881
1882[Upload Page](<app/(upload)/page.tsx>)
1883[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1884[Missing](<app/(missing)/file.md>)
1885"#;
1886
1887 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1888
1889 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1890 let result = rule.check(&ctx).unwrap();
1891
1892 assert_eq!(
1894 result.len(),
1895 1,
1896 "Should have exactly one warning for missing file. Got: {result:?}"
1897 );
1898 assert!(
1899 result[0].message.contains("app/(missing)/file.md"),
1900 "Warning should mention app/(missing)/file.md"
1901 );
1902 }
1903
1904 #[test]
1905 fn test_all_file_types_checked() {
1906 let temp_dir = tempdir().unwrap();
1908 let base_path = temp_dir.path();
1909
1910 let content = r#"
1912[Image Link](image.jpg)
1913[Video Link](video.mp4)
1914[Markdown Link](document.md)
1915[PDF Link](file.pdf)
1916"#;
1917
1918 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1919
1920 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1921 let result = rule.check(&ctx).unwrap();
1922
1923 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1925 }
1926
1927 #[test]
1928 fn test_code_span_detection() {
1929 let rule = MD057ExistingRelativeLinks::new();
1930
1931 let temp_dir = tempdir().unwrap();
1933 let base_path = temp_dir.path();
1934
1935 let rule = rule.with_path(base_path);
1936
1937 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1939
1940 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941 let result = rule.check(&ctx).unwrap();
1942
1943 assert_eq!(result.len(), 1, "Should only flag the real link");
1945 assert!(result[0].message.contains("nonexistent.md"));
1946 }
1947
1948 #[test]
1949 fn test_inline_code_spans() {
1950 let temp_dir = tempdir().unwrap();
1952 let base_path = temp_dir.path();
1953
1954 let content = r#"
1956# Test Document
1957
1958This is a normal link: [Link](missing.md)
1959
1960This is a code span with a link: `[Link](another-missing.md)`
1961
1962Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1963
1964 "#;
1965
1966 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1968
1969 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1971 let result = rule.check(&ctx).unwrap();
1972
1973 assert_eq!(result.len(), 1, "Should have exactly one warning");
1975 assert!(
1976 result[0].message.contains("missing.md"),
1977 "Warning should be for missing.md"
1978 );
1979 assert!(
1980 !result.iter().any(|w| w.message.contains("another-missing.md")),
1981 "Should not warn about link in code span"
1982 );
1983 assert!(
1984 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1985 "Should not warn about link in inline code"
1986 );
1987 }
1988
1989 #[test]
1990 fn test_extensionless_link_resolution() {
1991 let temp_dir = tempdir().unwrap();
1993 let base_path = temp_dir.path();
1994
1995 let page_path = base_path.join("page.md");
1997 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
1998
1999 let content = r#"
2001# Test Document
2002
2003[Link without extension](page)
2004[Link with extension](page.md)
2005[Missing link](nonexistent)
2006"#;
2007
2008 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2009
2010 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2011 let result = rule.check(&ctx).unwrap();
2012
2013 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2016 assert!(
2017 result[0].message.contains("nonexistent"),
2018 "Warning should be for 'nonexistent' not 'page'"
2019 );
2020 }
2021
2022 #[test]
2024 fn test_cross_file_scope() {
2025 let rule = MD057ExistingRelativeLinks::new();
2026 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2027 }
2028
2029 #[test]
2030 fn test_contribute_to_index_extracts_markdown_links() {
2031 let rule = MD057ExistingRelativeLinks::new();
2032 let content = r#"
2033# Document
2034
2035[Link to docs](./docs/guide.md)
2036[Link with fragment](./other.md#section)
2037[External link](https://example.com)
2038[Image link](image.png)
2039[Media file](video.mp4)
2040"#;
2041
2042 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2043 let mut index = FileIndex::new();
2044 rule.contribute_to_index(&ctx, &mut index);
2045
2046 assert_eq!(index.cross_file_links.len(), 2);
2048
2049 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2051 assert_eq!(index.cross_file_links[0].fragment, "");
2052
2053 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2055 assert_eq!(index.cross_file_links[1].fragment, "section");
2056 }
2057
2058 #[test]
2059 fn test_contribute_to_index_skips_external_and_anchors() {
2060 let rule = MD057ExistingRelativeLinks::new();
2061 let content = r#"
2062# Document
2063
2064[External](https://example.com)
2065[Another external](http://example.org)
2066[Fragment only](#section)
2067[FTP link](ftp://files.example.com)
2068[Mail link](mailto:test@example.com)
2069[WWW link](www.example.com)
2070"#;
2071
2072 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2073 let mut index = FileIndex::new();
2074 rule.contribute_to_index(&ctx, &mut index);
2075
2076 assert_eq!(index.cross_file_links.len(), 0);
2078 }
2079
2080 #[test]
2081 fn test_cross_file_check_valid_link() {
2082 use crate::workspace_index::WorkspaceIndex;
2083
2084 let rule = MD057ExistingRelativeLinks::new();
2085
2086 let mut workspace_index = WorkspaceIndex::new();
2088 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2089
2090 let mut file_index = FileIndex::new();
2092 file_index.add_cross_file_link(CrossFileLinkIndex {
2093 target_path: "guide.md".to_string(),
2094 fragment: "".to_string(),
2095 line: 5,
2096 column: 1,
2097 });
2098
2099 let warnings = rule
2101 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2102 .unwrap();
2103
2104 assert!(warnings.is_empty());
2106 }
2107
2108 #[test]
2109 fn test_cross_file_check_missing_link() {
2110 use crate::workspace_index::WorkspaceIndex;
2113
2114 let rule = MD057ExistingRelativeLinks::new();
2115 let workspace_index = WorkspaceIndex::new();
2116
2117 let mut file_index = FileIndex::new();
2118 file_index.add_cross_file_link(CrossFileLinkIndex {
2119 target_path: "missing.md".to_string(),
2120 fragment: "".to_string(),
2121 line: 5,
2122 column: 1,
2123 });
2124
2125 let warnings = rule
2126 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2127 .unwrap();
2128
2129 assert!(
2131 warnings.is_empty(),
2132 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2133 );
2134 }
2135
2136 #[test]
2137 fn test_cross_file_check_parent_path() {
2138 use crate::workspace_index::WorkspaceIndex;
2139
2140 let rule = MD057ExistingRelativeLinks::new();
2141
2142 let mut workspace_index = WorkspaceIndex::new();
2144 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2145
2146 let mut file_index = FileIndex::new();
2148 file_index.add_cross_file_link(CrossFileLinkIndex {
2149 target_path: "../readme.md".to_string(),
2150 fragment: "".to_string(),
2151 line: 5,
2152 column: 1,
2153 });
2154
2155 let warnings = rule
2157 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2158 .unwrap();
2159
2160 assert!(warnings.is_empty());
2162 }
2163
2164 #[test]
2165 fn test_cross_file_check_html_link_with_md_source() {
2166 use crate::workspace_index::WorkspaceIndex;
2169
2170 let rule = MD057ExistingRelativeLinks::new();
2171
2172 let mut workspace_index = WorkspaceIndex::new();
2174 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2175
2176 let mut file_index = FileIndex::new();
2178 file_index.add_cross_file_link(CrossFileLinkIndex {
2179 target_path: "guide.html".to_string(),
2180 fragment: "section".to_string(),
2181 line: 10,
2182 column: 5,
2183 });
2184
2185 let warnings = rule
2187 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2188 .unwrap();
2189
2190 assert!(
2192 warnings.is_empty(),
2193 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2194 );
2195 }
2196
2197 #[test]
2198 fn test_cross_file_check_html_link_without_source() {
2199 use crate::workspace_index::WorkspaceIndex;
2203
2204 let rule = MD057ExistingRelativeLinks::new();
2205 let workspace_index = WorkspaceIndex::new();
2206
2207 let mut file_index = FileIndex::new();
2208 file_index.add_cross_file_link(CrossFileLinkIndex {
2209 target_path: "missing.html".to_string(),
2210 fragment: "".to_string(),
2211 line: 10,
2212 column: 5,
2213 });
2214
2215 let warnings = rule
2216 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2217 .unwrap();
2218
2219 assert!(
2221 warnings.is_empty(),
2222 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2223 );
2224 }
2225
2226 #[test]
2227 fn test_normalize_path_function() {
2228 assert_eq!(
2230 normalize_path(Path::new("docs/guide.md")),
2231 PathBuf::from("docs/guide.md")
2232 );
2233
2234 assert_eq!(
2236 normalize_path(Path::new("./docs/guide.md")),
2237 PathBuf::from("docs/guide.md")
2238 );
2239
2240 assert_eq!(
2242 normalize_path(Path::new("docs/sub/../guide.md")),
2243 PathBuf::from("docs/guide.md")
2244 );
2245
2246 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2248 }
2249
2250 #[test]
2251 fn test_html_link_with_md_source() {
2252 let temp_dir = tempdir().unwrap();
2254 let base_path = temp_dir.path();
2255
2256 let md_file = base_path.join("guide.md");
2258 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2259
2260 let content = r#"
2261[Read the guide](guide.html)
2262[Also here](getting-started.html)
2263"#;
2264
2265 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2266 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2267 let result = rule.check(&ctx).unwrap();
2268
2269 assert_eq!(
2271 result.len(),
2272 1,
2273 "Should only warn about missing source. Got: {result:?}"
2274 );
2275 assert!(result[0].message.contains("getting-started.html"));
2276 }
2277
2278 #[test]
2279 fn test_htm_link_with_md_source() {
2280 let temp_dir = tempdir().unwrap();
2282 let base_path = temp_dir.path();
2283
2284 let md_file = base_path.join("page.md");
2285 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2286
2287 let content = "[Page](page.htm)";
2288
2289 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2290 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2291 let result = rule.check(&ctx).unwrap();
2292
2293 assert!(
2294 result.is_empty(),
2295 "Should not warn when .md source exists for .htm link"
2296 );
2297 }
2298
2299 #[test]
2300 fn test_html_link_finds_various_markdown_extensions() {
2301 let temp_dir = tempdir().unwrap();
2303 let base_path = temp_dir.path();
2304
2305 File::create(base_path.join("doc.md")).unwrap();
2306 File::create(base_path.join("tutorial.mdx")).unwrap();
2307 File::create(base_path.join("guide.markdown")).unwrap();
2308
2309 let content = r#"
2310[Doc](doc.html)
2311[Tutorial](tutorial.html)
2312[Guide](guide.html)
2313"#;
2314
2315 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2316 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2317 let result = rule.check(&ctx).unwrap();
2318
2319 assert!(
2320 result.is_empty(),
2321 "Should find all markdown variants as source files. Got: {result:?}"
2322 );
2323 }
2324
2325 #[test]
2326 fn test_html_link_in_subdirectory() {
2327 let temp_dir = tempdir().unwrap();
2329 let base_path = temp_dir.path();
2330
2331 let docs_dir = base_path.join("docs");
2332 std::fs::create_dir(&docs_dir).unwrap();
2333 File::create(docs_dir.join("guide.md"))
2334 .unwrap()
2335 .write_all(b"# Guide")
2336 .unwrap();
2337
2338 let content = "[Guide](docs/guide.html)";
2339
2340 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2341 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2342 let result = rule.check(&ctx).unwrap();
2343
2344 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2345 }
2346
2347 #[test]
2348 fn test_absolute_path_skipped_in_check() {
2349 let temp_dir = tempdir().unwrap();
2352 let base_path = temp_dir.path();
2353
2354 let content = r#"
2355# Test Document
2356
2357[Go Runtime](/pkg/runtime)
2358[Go Runtime with Fragment](/pkg/runtime#section)
2359[API Docs](/api/v1/users)
2360[Blog Post](/blog/2024/release.html)
2361[React Hook](/react/hooks/use-state.html)
2362"#;
2363
2364 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2365 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2366 let result = rule.check(&ctx).unwrap();
2367
2368 assert!(
2370 result.is_empty(),
2371 "Absolute paths should be skipped. Got warnings: {result:?}"
2372 );
2373 }
2374
2375 #[test]
2376 fn test_absolute_path_skipped_in_cross_file_check() {
2377 use crate::workspace_index::WorkspaceIndex;
2379
2380 let rule = MD057ExistingRelativeLinks::new();
2381
2382 let workspace_index = WorkspaceIndex::new();
2384
2385 let mut file_index = FileIndex::new();
2387 file_index.add_cross_file_link(CrossFileLinkIndex {
2388 target_path: "/pkg/runtime.md".to_string(),
2389 fragment: "".to_string(),
2390 line: 5,
2391 column: 1,
2392 });
2393 file_index.add_cross_file_link(CrossFileLinkIndex {
2394 target_path: "/api/v1/users.md".to_string(),
2395 fragment: "section".to_string(),
2396 line: 10,
2397 column: 1,
2398 });
2399
2400 let warnings = rule
2402 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2403 .unwrap();
2404
2405 assert!(
2407 warnings.is_empty(),
2408 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2409 );
2410 }
2411
2412 #[test]
2413 fn test_protocol_relative_url_not_skipped() {
2414 let temp_dir = tempdir().unwrap();
2417 let base_path = temp_dir.path();
2418
2419 let content = r#"
2420# Test Document
2421
2422[External](//example.com/page)
2423[Another](//cdn.example.com/asset.js)
2424"#;
2425
2426 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2427 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2428 let result = rule.check(&ctx).unwrap();
2429
2430 assert!(
2432 result.is_empty(),
2433 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2434 );
2435 }
2436
2437 #[test]
2438 fn test_email_addresses_skipped() {
2439 let temp_dir = tempdir().unwrap();
2442 let base_path = temp_dir.path();
2443
2444 let content = r#"
2445# Test Document
2446
2447[Contact](user@example.com)
2448[Steering](steering@kubernetes.io)
2449[Support](john.doe+filter@company.co.uk)
2450[User](user_name@sub.domain.com)
2451"#;
2452
2453 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2454 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2455 let result = rule.check(&ctx).unwrap();
2456
2457 assert!(
2459 result.is_empty(),
2460 "Email addresses should be skipped. Got warnings: {result:?}"
2461 );
2462 }
2463
2464 #[test]
2465 fn test_email_addresses_vs_file_paths() {
2466 let temp_dir = tempdir().unwrap();
2469 let base_path = temp_dir.path();
2470
2471 let content = r#"
2472# Test Document
2473
2474[Email](user@example.com) <!-- Should be skipped (email) -->
2475[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2476[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2477"#;
2478
2479 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2480 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2481 let result = rule.check(&ctx).unwrap();
2482
2483 assert!(
2485 result.is_empty(),
2486 "All email addresses should be skipped. Got: {result:?}"
2487 );
2488 }
2489
2490 #[test]
2491 fn test_diagnostic_position_accuracy() {
2492 let temp_dir = tempdir().unwrap();
2494 let base_path = temp_dir.path();
2495
2496 let content = "prefix [text](missing.md) suffix";
2499 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2503 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2504 let result = rule.check(&ctx).unwrap();
2505
2506 assert_eq!(result.len(), 1, "Should have exactly one warning");
2507 assert_eq!(result[0].line, 1, "Should be on line 1");
2508 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2509 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2510 }
2511
2512 #[test]
2513 fn test_diagnostic_position_non_ascii_link() {
2514 let temp_dir = tempdir().unwrap();
2517 let base_path = temp_dir.path();
2518
2519 let content = "你好你好[你好](not-exist.md) bar";
2523
2524 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2525 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2526 let result = rule.check(&ctx).unwrap();
2527
2528 assert_eq!(result.len(), 1, "Should have exactly one warning");
2529 assert_eq!(result[0].line, 1, "Should be on line 1");
2530 assert_eq!(
2531 result[0].column, 10,
2532 "Column must be a character offset, not a byte offset"
2533 );
2534 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2535 }
2536
2537 #[test]
2538 fn test_diagnostic_position_angle_brackets() {
2539 let temp_dir = tempdir().unwrap();
2541 let base_path = temp_dir.path();
2542
2543 let content = "[link](<missing.md>)";
2546 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2549 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2550 let result = rule.check(&ctx).unwrap();
2551
2552 assert_eq!(result.len(), 1, "Should have exactly one warning");
2553 assert_eq!(result[0].line, 1, "Should be on line 1");
2554 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2555 }
2556
2557 #[test]
2558 fn test_diagnostic_position_multiline() {
2559 let temp_dir = tempdir().unwrap();
2561 let base_path = temp_dir.path();
2562
2563 let content = r#"# Title
2564Some text on line 2
2565[link on line 3](missing1.md)
2566More text
2567[link on line 5](missing2.md)"#;
2568
2569 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2570 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2571 let result = rule.check(&ctx).unwrap();
2572
2573 assert_eq!(result.len(), 2, "Should have two warnings");
2574
2575 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2577 assert!(result[0].message.contains("missing1.md"));
2578
2579 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2581 assert!(result[1].message.contains("missing2.md"));
2582 }
2583
2584 #[test]
2585 fn test_diagnostic_position_with_spaces() {
2586 let temp_dir = tempdir().unwrap();
2588 let base_path = temp_dir.path();
2589
2590 let content = "[link]( missing.md )";
2591 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2596 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2597 let result = rule.check(&ctx).unwrap();
2598
2599 assert_eq!(result.len(), 1, "Should have exactly one warning");
2600 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2602 }
2603
2604 #[test]
2605 fn test_diagnostic_position_image() {
2606 let temp_dir = tempdir().unwrap();
2608 let base_path = temp_dir.path();
2609
2610 let content = "";
2611
2612 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2613 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2614 let result = rule.check(&ctx).unwrap();
2615
2616 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2617 assert_eq!(result[0].line, 1);
2618 assert!(result[0].column > 0, "Should have valid column position");
2620 assert!(result[0].message.contains("missing.jpg"));
2621 }
2622
2623 #[test]
2624 fn test_diagnostic_position_non_ascii_image() {
2625 let temp_dir = tempdir().unwrap();
2627 let base_path = temp_dir.path();
2628
2629 let content = "你好你好";
2632
2633 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2634 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2635 let result = rule.check(&ctx).unwrap();
2636
2637 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2638 assert_eq!(result[0].line, 1, "Should be on line 1");
2639 assert_eq!(
2640 result[0].column, 5,
2641 "Column must be a character offset, not a byte offset"
2642 );
2643 assert!(result[0].message.contains("not-exist.png"));
2644 }
2645
2646 #[test]
2647 fn test_diagnostic_position_non_ascii_reference_def() {
2648 let temp_dir = tempdir().unwrap();
2652 let base_path = temp_dir.path();
2653
2654 let content = "[你好]: not-exist.md";
2657
2658 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2659 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2660 let result = rule.check(&ctx).unwrap();
2661
2662 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2663 assert_eq!(result[0].line, 1, "Should be on line 1");
2664 assert_eq!(
2665 result[0].column, 7,
2666 "Column must be a character offset, not a byte offset"
2667 );
2668 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2669 }
2670
2671 #[test]
2672 fn test_wikilinks_skipped() {
2673 let temp_dir = tempdir().unwrap();
2676 let base_path = temp_dir.path();
2677
2678 let content = r#"# Test Document
2679
2680[[Microsoft#Windows OS]]
2681[[SomePage]]
2682[[Page With Spaces]]
2683[[path/to/page#section]]
2684[[page|Display Text]]
2685
2686This is a [real missing link](missing.md) that should be flagged.
2687"#;
2688
2689 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2690 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2691 let result = rule.check(&ctx).unwrap();
2692
2693 assert_eq!(
2695 result.len(),
2696 1,
2697 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2698 );
2699 assert!(
2700 result[0].message.contains("missing.md"),
2701 "Warning should be for missing.md, not wikilinks"
2702 );
2703 }
2704
2705 #[test]
2706 fn test_wikilinks_not_added_to_index() {
2707 let temp_dir = tempdir().unwrap();
2709 let base_path = temp_dir.path();
2710
2711 let content = r#"# Test Document
2712
2713[[Microsoft#Windows OS]]
2714[[SomePage#section]]
2715[Regular Link](other.md)
2716"#;
2717
2718 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2719 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2720
2721 let mut file_index = FileIndex::new();
2722 rule.contribute_to_index(&ctx, &mut file_index);
2723
2724 let cross_file_links = &file_index.cross_file_links;
2727 assert_eq!(
2728 cross_file_links.len(),
2729 1,
2730 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2731 );
2732 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2733 }
2734
2735 #[test]
2736 fn test_reference_definition_missing_file() {
2737 let temp_dir = tempdir().unwrap();
2739 let base_path = temp_dir.path();
2740
2741 let content = r#"# Test Document
2742
2743[test]: ./missing.md
2744[example]: ./nonexistent.html
2745
2746Use [test] and [example] here.
2747"#;
2748
2749 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2750 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2751 let result = rule.check(&ctx).unwrap();
2752
2753 assert_eq!(
2755 result.len(),
2756 2,
2757 "Should have warnings for missing reference definition targets. Got: {result:?}"
2758 );
2759 assert!(
2760 result.iter().any(|w| w.message.contains("missing.md")),
2761 "Should warn about missing.md"
2762 );
2763 assert!(
2764 result.iter().any(|w| w.message.contains("nonexistent.html")),
2765 "Should warn about nonexistent.html"
2766 );
2767 }
2768
2769 #[test]
2770 fn test_reference_definition_existing_file() {
2771 let temp_dir = tempdir().unwrap();
2773 let base_path = temp_dir.path();
2774
2775 let exists_path = base_path.join("exists.md");
2777 File::create(&exists_path)
2778 .unwrap()
2779 .write_all(b"# Existing file")
2780 .unwrap();
2781
2782 let content = r#"# Test Document
2783
2784[test]: ./exists.md
2785
2786Use [test] here.
2787"#;
2788
2789 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2790 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2791 let result = rule.check(&ctx).unwrap();
2792
2793 assert!(
2795 result.is_empty(),
2796 "Should not warn about existing file. Got: {result:?}"
2797 );
2798 }
2799
2800 #[test]
2801 fn test_reference_definition_external_url_skipped() {
2802 let temp_dir = tempdir().unwrap();
2804 let base_path = temp_dir.path();
2805
2806 let content = r#"# Test Document
2807
2808[google]: https://google.com
2809[example]: http://example.org
2810[mail]: mailto:test@example.com
2811[ftp]: ftp://files.example.com
2812[local]: ./missing.md
2813
2814Use [google], [example], [mail], [ftp], [local] here.
2815"#;
2816
2817 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2818 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2819 let result = rule.check(&ctx).unwrap();
2820
2821 assert_eq!(
2823 result.len(),
2824 1,
2825 "Should only warn about local missing file. Got: {result:?}"
2826 );
2827 assert!(
2828 result[0].message.contains("missing.md"),
2829 "Warning should be for missing.md"
2830 );
2831 }
2832
2833 #[test]
2834 fn test_reference_definition_fragment_only_skipped() {
2835 let temp_dir = tempdir().unwrap();
2837 let base_path = temp_dir.path();
2838
2839 let content = r#"# Test Document
2840
2841[section]: #my-section
2842
2843Use [section] here.
2844"#;
2845
2846 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2847 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2848 let result = rule.check(&ctx).unwrap();
2849
2850 assert!(
2852 result.is_empty(),
2853 "Should not warn about fragment-only reference. Got: {result:?}"
2854 );
2855 }
2856
2857 #[test]
2858 fn test_reference_definition_column_position() {
2859 let temp_dir = tempdir().unwrap();
2861 let base_path = temp_dir.path();
2862
2863 let content = "[ref]: ./missing.md";
2866 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2870 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2871 let result = rule.check(&ctx).unwrap();
2872
2873 assert_eq!(result.len(), 1, "Should have exactly one warning");
2874 assert_eq!(result[0].line, 1, "Should be on line 1");
2875 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2876 }
2877
2878 #[test]
2879 fn test_reference_definition_html_with_md_source() {
2880 let temp_dir = tempdir().unwrap();
2882 let base_path = temp_dir.path();
2883
2884 let md_file = base_path.join("guide.md");
2886 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2887
2888 let content = r#"# Test Document
2889
2890[guide]: ./guide.html
2891[missing]: ./missing.html
2892
2893Use [guide] and [missing] here.
2894"#;
2895
2896 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2897 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2898 let result = rule.check(&ctx).unwrap();
2899
2900 assert_eq!(
2902 result.len(),
2903 1,
2904 "Should only warn about missing source. Got: {result:?}"
2905 );
2906 assert!(result[0].message.contains("missing.html"));
2907 }
2908
2909 #[test]
2910 fn test_reference_definition_url_encoded() {
2911 let temp_dir = tempdir().unwrap();
2913 let base_path = temp_dir.path();
2914
2915 let file_with_spaces = base_path.join("file with spaces.md");
2917 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2918
2919 let content = r#"# Test Document
2920
2921[spaces]: ./file%20with%20spaces.md
2922[missing]: ./missing%20file.md
2923
2924Use [spaces] and [missing] here.
2925"#;
2926
2927 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2928 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2929 let result = rule.check(&ctx).unwrap();
2930
2931 assert_eq!(
2933 result.len(),
2934 1,
2935 "Should only warn about missing URL-encoded file. Got: {result:?}"
2936 );
2937 assert!(result[0].message.contains("missing%20file.md"));
2938 }
2939
2940 #[test]
2941 fn test_inline_and_reference_both_checked() {
2942 let temp_dir = tempdir().unwrap();
2944 let base_path = temp_dir.path();
2945
2946 let content = r#"# Test Document
2947
2948[inline link](./inline-missing.md)
2949[ref]: ./ref-missing.md
2950
2951Use [ref] here.
2952"#;
2953
2954 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2955 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2956 let result = rule.check(&ctx).unwrap();
2957
2958 assert_eq!(
2960 result.len(),
2961 2,
2962 "Should warn about both inline and reference links. Got: {result:?}"
2963 );
2964 assert!(
2965 result.iter().any(|w| w.message.contains("inline-missing.md")),
2966 "Should warn about inline-missing.md"
2967 );
2968 assert!(
2969 result.iter().any(|w| w.message.contains("ref-missing.md")),
2970 "Should warn about ref-missing.md"
2971 );
2972 }
2973
2974 #[test]
2975 fn test_footnote_definitions_not_flagged() {
2976 let rule = MD057ExistingRelativeLinks::default();
2979
2980 let content = r#"# Title
2981
2982A footnote[^1].
2983
2984[^1]: [link](https://www.google.com).
2985"#;
2986
2987 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2988 let result = rule.check(&ctx).unwrap();
2989
2990 assert!(
2991 result.is_empty(),
2992 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2993 );
2994 }
2995
2996 #[test]
2997 fn test_footnote_with_relative_link_inside() {
2998 let rule = MD057ExistingRelativeLinks::default();
3001
3002 let content = r#"# Title
3003
3004See the footnote[^1].
3005
3006[^1]: Check out [this file](./existing.md) for more info.
3007[^2]: Also see [missing](./does-not-exist.md).
3008"#;
3009
3010 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3011 let result = rule.check(&ctx).unwrap();
3012
3013 for warning in &result {
3018 assert!(
3019 !warning.message.contains("[this file]"),
3020 "Footnote content should not be treated as URL: {warning:?}"
3021 );
3022 assert!(
3023 !warning.message.contains("[missing]"),
3024 "Footnote content should not be treated as URL: {warning:?}"
3025 );
3026 }
3027 }
3028
3029 #[test]
3030 fn test_mixed_footnotes_and_reference_definitions() {
3031 let temp_dir = tempdir().unwrap();
3033 let base_path = temp_dir.path();
3034
3035 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3036
3037 let content = r#"# Title
3038
3039A footnote[^1] and a [ref link][myref].
3040
3041[^1]: This is a footnote with [link](https://example.com).
3042
3043[myref]: ./missing-file.md "This should be checked"
3044"#;
3045
3046 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3047 let result = rule.check(&ctx).unwrap();
3048
3049 assert_eq!(
3051 result.len(),
3052 1,
3053 "Should only warn about the regular reference definition. Got: {result:?}"
3054 );
3055 assert!(
3056 result[0].message.contains("missing-file.md"),
3057 "Should warn about missing-file.md in reference definition"
3058 );
3059 }
3060
3061 #[test]
3062 fn test_absolute_links_ignore_by_default() {
3063 let temp_dir = tempdir().unwrap();
3065 let base_path = temp_dir.path();
3066
3067 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3068
3069 let content = r#"# Links
3070
3071[API docs](/api/v1/users)
3072[Blog post](/blog/2024/release.html)
3073
3074
3075[ref]: /docs/reference.md
3076"#;
3077
3078 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3079 let result = rule.check(&ctx).unwrap();
3080
3081 assert!(
3083 result.is_empty(),
3084 "Absolute links should be ignored by default. Got: {result:?}"
3085 );
3086 }
3087
3088 #[test]
3089 fn test_absolute_links_warn_config() {
3090 let temp_dir = tempdir().unwrap();
3092 let base_path = temp_dir.path();
3093
3094 let config = MD057Config {
3095 absolute_links: AbsoluteLinksOption::Warn,
3096 ..Default::default()
3097 };
3098 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3099
3100 let content = r#"# Links
3101
3102[API docs](/api/v1/users)
3103[Blog post](/blog/2024/release.html)
3104"#;
3105
3106 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3107 let result = rule.check(&ctx).unwrap();
3108
3109 assert_eq!(
3111 result.len(),
3112 2,
3113 "Should warn about both absolute links. Got: {result:?}"
3114 );
3115 assert!(
3116 result[0].message.contains("cannot be validated locally"),
3117 "Warning should explain why: {}",
3118 result[0].message
3119 );
3120 assert!(
3121 result[0].message.contains("/api/v1/users"),
3122 "Warning should include the link path"
3123 );
3124 }
3125
3126 #[test]
3127 fn test_absolute_links_warn_images() {
3128 let temp_dir = tempdir().unwrap();
3130 let base_path = temp_dir.path();
3131
3132 let config = MD057Config {
3133 absolute_links: AbsoluteLinksOption::Warn,
3134 ..Default::default()
3135 };
3136 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3137
3138 let content = r#"# Images
3139
3140
3141"#;
3142
3143 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3144 let result = rule.check(&ctx).unwrap();
3145
3146 assert_eq!(
3147 result.len(),
3148 1,
3149 "Should warn about absolute image path. Got: {result:?}"
3150 );
3151 assert!(
3152 result[0].message.contains("/assets/logo.png"),
3153 "Warning should include the image path"
3154 );
3155 }
3156
3157 #[test]
3158 fn test_absolute_links_warn_reference_definitions() {
3159 let temp_dir = tempdir().unwrap();
3161 let base_path = temp_dir.path();
3162
3163 let config = MD057Config {
3164 absolute_links: AbsoluteLinksOption::Warn,
3165 ..Default::default()
3166 };
3167 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3168
3169 let content = r#"# Reference
3170
3171See the [docs][ref].
3172
3173[ref]: /docs/reference.md
3174"#;
3175
3176 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3177 let result = rule.check(&ctx).unwrap();
3178
3179 assert_eq!(
3180 result.len(),
3181 1,
3182 "Should warn about absolute reference definition. Got: {result:?}"
3183 );
3184 assert!(
3185 result[0].message.contains("/docs/reference.md"),
3186 "Warning should include the reference path"
3187 );
3188 }
3189
3190 #[test]
3191 fn test_search_paths_inline_link() {
3192 let temp_dir = tempdir().unwrap();
3193 let base_path = temp_dir.path();
3194
3195 let assets_dir = base_path.join("assets");
3197 std::fs::create_dir_all(&assets_dir).unwrap();
3198 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3199
3200 let config = MD057Config {
3201 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3202 ..Default::default()
3203 };
3204 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3205
3206 let content = "# Test\n\n[Photo](photo.png)\n";
3207 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3208 let result = rule.check(&ctx).unwrap();
3209
3210 assert!(
3211 result.is_empty(),
3212 "Should find photo.png via search-paths. Got: {result:?}"
3213 );
3214 }
3215
3216 #[test]
3217 fn test_search_paths_image() {
3218 let temp_dir = tempdir().unwrap();
3219 let base_path = temp_dir.path();
3220
3221 let assets_dir = base_path.join("attachments");
3222 std::fs::create_dir_all(&assets_dir).unwrap();
3223 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3224
3225 let config = MD057Config {
3226 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3227 ..Default::default()
3228 };
3229 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3230
3231 let content = "# Test\n\n\n";
3232 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3233 let result = rule.check(&ctx).unwrap();
3234
3235 assert!(
3236 result.is_empty(),
3237 "Should find diagram.svg via search-paths. Got: {result:?}"
3238 );
3239 }
3240
3241 #[test]
3242 fn test_search_paths_reference_definition() {
3243 let temp_dir = tempdir().unwrap();
3244 let base_path = temp_dir.path();
3245
3246 let assets_dir = base_path.join("images");
3247 std::fs::create_dir_all(&assets_dir).unwrap();
3248 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3249
3250 let config = MD057Config {
3251 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3252 ..Default::default()
3253 };
3254 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3255
3256 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3257 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3258 let result = rule.check(&ctx).unwrap();
3259
3260 assert!(
3261 result.is_empty(),
3262 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3263 );
3264 }
3265
3266 #[test]
3267 fn test_search_paths_still_warns_when_truly_missing() {
3268 let temp_dir = tempdir().unwrap();
3269 let base_path = temp_dir.path();
3270
3271 let assets_dir = base_path.join("assets");
3272 std::fs::create_dir_all(&assets_dir).unwrap();
3273
3274 let config = MD057Config {
3275 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3276 ..Default::default()
3277 };
3278 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3279
3280 let content = "# Test\n\n\n";
3281 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3282 let result = rule.check(&ctx).unwrap();
3283
3284 assert_eq!(
3285 result.len(),
3286 1,
3287 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3288 );
3289 }
3290
3291 #[test]
3292 fn test_search_paths_nonexistent_directory() {
3293 let temp_dir = tempdir().unwrap();
3294 let base_path = temp_dir.path();
3295
3296 let config = MD057Config {
3297 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3298 ..Default::default()
3299 };
3300 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3301
3302 let content = "# Test\n\n\n";
3303 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3304 let result = rule.check(&ctx).unwrap();
3305
3306 assert_eq!(
3307 result.len(),
3308 1,
3309 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3310 );
3311 }
3312
3313 #[test]
3314 fn test_obsidian_attachment_folder_named() {
3315 let temp_dir = tempdir().unwrap();
3316 let vault = temp_dir.path().join("vault");
3317 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3318 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3319 std::fs::create_dir_all(vault.join("notes")).unwrap();
3320
3321 std::fs::write(
3322 vault.join(".obsidian/app.json"),
3323 r#"{"attachmentFolderPath": "Attachments"}"#,
3324 )
3325 .unwrap();
3326 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3327
3328 let notes_dir = vault.join("notes");
3329 let source_file = notes_dir.join("test.md");
3330 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3331
3332 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3333
3334 let content = "# Test\n\n\n";
3335 let ctx =
3336 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3337 let result = rule.check(&ctx).unwrap();
3338
3339 assert!(
3340 result.is_empty(),
3341 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3342 );
3343 }
3344
3345 #[test]
3346 fn test_obsidian_attachment_same_folder_as_file() {
3347 let temp_dir = tempdir().unwrap();
3348 let vault = temp_dir.path().join("vault-rf");
3349 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3350 std::fs::create_dir_all(vault.join("notes")).unwrap();
3351
3352 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3353
3354 let notes_dir = vault.join("notes");
3356 let source_file = notes_dir.join("test.md");
3357 std::fs::write(&source_file, "placeholder").unwrap();
3358 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3359
3360 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3361
3362 let content = "# Test\n\n\n";
3363 let ctx =
3364 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3365 let result = rule.check(&ctx).unwrap();
3366
3367 assert!(
3368 result.is_empty(),
3369 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3375 let temp_dir = tempdir().unwrap();
3376 let vault = temp_dir.path().join("vault-nf");
3377 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3378 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3379 std::fs::create_dir_all(vault.join("notes")).unwrap();
3380
3381 std::fs::write(
3382 vault.join(".obsidian/app.json"),
3383 r#"{"attachmentFolderPath": "Attachments"}"#,
3384 )
3385 .unwrap();
3386 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3387
3388 let notes_dir = vault.join("notes");
3389 let source_file = notes_dir.join("test.md");
3390 std::fs::write(&source_file, "placeholder").unwrap();
3391
3392 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3393
3394 let content = "# Test\n\n\n";
3395 let ctx =
3397 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3398 let result = rule.check(&ctx).unwrap();
3399
3400 assert_eq!(
3401 result.len(),
3402 1,
3403 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3404 );
3405 }
3406
3407 #[test]
3408 fn test_search_paths_combined_with_obsidian() {
3409 let temp_dir = tempdir().unwrap();
3410 let vault = temp_dir.path().join("vault-combo");
3411 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3412 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3413 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3414 std::fs::create_dir_all(vault.join("notes")).unwrap();
3415
3416 std::fs::write(
3417 vault.join(".obsidian/app.json"),
3418 r#"{"attachmentFolderPath": "Attachments"}"#,
3419 )
3420 .unwrap();
3421 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3422 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3423
3424 let notes_dir = vault.join("notes");
3425 let source_file = notes_dir.join("test.md");
3426 std::fs::write(&source_file, "placeholder").unwrap();
3427
3428 let extra_assets_dir = vault.join("extra-assets");
3429 let config = MD057Config {
3430 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3431 ..Default::default()
3432 };
3433 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3434
3435 let content = "# Test\n\n\n\n\n";
3437 let ctx =
3438 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3439 let result = rule.check(&ctx).unwrap();
3440
3441 assert!(
3442 result.is_empty(),
3443 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3444 );
3445 }
3446
3447 #[test]
3448 fn test_obsidian_attachment_subfolder_under_file() {
3449 let temp_dir = tempdir().unwrap();
3450 let vault = temp_dir.path().join("vault-sub");
3451 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3452 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3453
3454 std::fs::write(
3455 vault.join(".obsidian/app.json"),
3456 r#"{"attachmentFolderPath": "./assets"}"#,
3457 )
3458 .unwrap();
3459 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3460
3461 let notes_dir = vault.join("notes");
3462 let source_file = notes_dir.join("test.md");
3463 std::fs::write(&source_file, "placeholder").unwrap();
3464
3465 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3466
3467 let content = "# Test\n\n\n";
3468 let ctx =
3469 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3470 let result = rule.check(&ctx).unwrap();
3471
3472 assert!(
3473 result.is_empty(),
3474 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3475 );
3476 }
3477
3478 #[test]
3479 fn test_obsidian_attachment_vault_root() {
3480 let temp_dir = tempdir().unwrap();
3481 let vault = temp_dir.path().join("vault-root");
3482 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3483 std::fs::create_dir_all(vault.join("notes")).unwrap();
3484
3485 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3487 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3488
3489 let notes_dir = vault.join("notes");
3490 let source_file = notes_dir.join("test.md");
3491 std::fs::write(&source_file, "placeholder").unwrap();
3492
3493 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3494
3495 let content = "# Test\n\n\n";
3496 let ctx =
3497 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3498 let result = rule.check(&ctx).unwrap();
3499
3500 assert!(
3501 result.is_empty(),
3502 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3503 );
3504 }
3505
3506 #[test]
3507 fn test_search_paths_multiple_directories() {
3508 let temp_dir = tempdir().unwrap();
3509 let base_path = temp_dir.path();
3510
3511 let dir_a = base_path.join("dir-a");
3512 let dir_b = base_path.join("dir-b");
3513 std::fs::create_dir_all(&dir_a).unwrap();
3514 std::fs::create_dir_all(&dir_b).unwrap();
3515 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3516 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3517
3518 let config = MD057Config {
3519 search_paths: vec![
3520 dir_a.to_string_lossy().into_owned(),
3521 dir_b.to_string_lossy().into_owned(),
3522 ],
3523 ..Default::default()
3524 };
3525 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3526
3527 let content = "# Test\n\n\n\n\n";
3528 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3529 let result = rule.check(&ctx).unwrap();
3530
3531 assert!(
3532 result.is_empty(),
3533 "Should find files across multiple search paths. Got: {result:?}"
3534 );
3535 }
3536
3537 #[test]
3538 fn test_cross_file_check_with_search_paths() {
3539 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3540
3541 let temp_dir = tempdir().unwrap();
3542 let base_path = temp_dir.path();
3543
3544 let docs_dir = base_path.join("docs");
3546 std::fs::create_dir_all(&docs_dir).unwrap();
3547 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3548
3549 let config = MD057Config {
3550 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3551 ..Default::default()
3552 };
3553 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3554
3555 let file_path = base_path.join("README.md");
3556 std::fs::write(&file_path, "# Readme\n").unwrap();
3557
3558 let mut file_index = FileIndex::default();
3559 file_index.cross_file_links.push(CrossFileLinkIndex {
3560 target_path: "guide.md".to_string(),
3561 fragment: String::new(),
3562 line: 3,
3563 column: 1,
3564 });
3565
3566 let workspace_index = WorkspaceIndex::new();
3567
3568 let result = rule
3569 .cross_file_check(&file_path, &file_index, &workspace_index)
3570 .unwrap();
3571
3572 assert!(
3573 result.is_empty(),
3574 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3575 );
3576 }
3577
3578 #[test]
3579 fn test_cross_file_check_with_obsidian_flavor() {
3580 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3581
3582 let temp_dir = tempdir().unwrap();
3583 let vault = temp_dir.path().join("vault-xf");
3584 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3585 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3586 std::fs::create_dir_all(vault.join("notes")).unwrap();
3587
3588 std::fs::write(
3589 vault.join(".obsidian/app.json"),
3590 r#"{"attachmentFolderPath": "Attachments"}"#,
3591 )
3592 .unwrap();
3593 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3594
3595 let notes_dir = vault.join("notes");
3596 let file_path = notes_dir.join("test.md");
3597 std::fs::write(&file_path, "placeholder").unwrap();
3598
3599 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3600 .with_path(¬es_dir)
3601 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3602
3603 let mut file_index = FileIndex::default();
3604 file_index.cross_file_links.push(CrossFileLinkIndex {
3605 target_path: "ref.md".to_string(),
3606 fragment: String::new(),
3607 line: 3,
3608 column: 1,
3609 });
3610
3611 let workspace_index = WorkspaceIndex::new();
3612
3613 let result = rule
3614 .cross_file_check(&file_path, &file_index, &workspace_index)
3615 .unwrap();
3616
3617 assert!(
3618 result.is_empty(),
3619 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3620 );
3621 }
3622
3623 #[test]
3624 fn test_check_clears_stale_cache() {
3625 let temp_dir = tempdir().unwrap();
3628 let base_path = temp_dir.path();
3629
3630 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3631
3632 let phantom_path = base_path.join("phantom.md");
3634 {
3635 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3636 cache.insert(phantom_path.clone(), true);
3637 }
3638
3639 let content = "[phantom](phantom.md)\n";
3640 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3641 let warnings = rule.check(&ctx).unwrap();
3642
3643 assert_eq!(
3645 warnings.len(),
3646 1,
3647 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3648 );
3649 assert!(warnings[0].message.contains("phantom.md"));
3650 }
3651
3652 #[test]
3653 fn test_check_does_not_carry_over_cache_between_runs() {
3654 let temp_dir = tempdir().unwrap();
3656 let base_path = temp_dir.path();
3657
3658 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3659
3660 let content = "[missing](nonexistent.md)\n";
3661 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3662
3663 let warnings_1 = rule.check(&ctx).unwrap();
3665 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3666
3667 let nonexistent_path = base_path.join("nonexistent.md");
3669 {
3670 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3671 cache.insert(nonexistent_path.clone(), true);
3672 }
3673
3674 let warnings_2 = rule.check(&ctx).unwrap();
3676 assert_eq!(
3677 warnings_2.len(),
3678 1,
3679 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3680 );
3681 }
3682
3683 #[test]
3689 fn test_no_duplicate_warnings_for_broken_relative_link() {
3690 use crate::workspace_index::WorkspaceIndex;
3691
3692 let temp_dir = tempdir().unwrap();
3693 let base_path = temp_dir.path();
3694
3695 let source_file = base_path.join("index.md");
3697 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3698
3699 let content = "[broken](does/not/exist.md)\n";
3700
3701 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3702
3703 let ctx = crate::lint_context::LintContext::new(
3705 content,
3706 crate::config::MarkdownFlavor::Standard,
3707 Some(source_file.clone()),
3708 );
3709 let check_warnings = rule.check(&ctx).unwrap();
3710
3711 let mut file_index = FileIndex::new();
3713 rule.contribute_to_index(&ctx, &mut file_index);
3714 let workspace_index = WorkspaceIndex::new();
3715 let cross_warnings = rule
3716 .cross_file_check(&source_file, &file_index, &workspace_index)
3717 .unwrap();
3718
3719 let total = check_warnings.len() + cross_warnings.len();
3720 assert_eq!(
3721 total, 1,
3722 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3723 check={check_warnings:?}, cross={cross_warnings:?}"
3724 );
3725 }
3726
3727 #[test]
3732 fn test_absolute_dir_link_accepted_relative_to_roots() {
3733 let temp_dir = tempdir().unwrap();
3734 let root = temp_dir.path();
3735
3736 let dir_d = root.join("d");
3738 std::fs::create_dir_all(&dir_d).unwrap();
3739 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3740
3741 let content = "\
3744[absolute dir](/d)\n\
3745[relative dir](d)\n\
3746[absolute file](/d/foo.md)\n\
3747[relative file](d/foo.md)\n";
3748
3749 let config = MD057Config {
3750 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3751 roots: vec![],
3752 ..Default::default()
3753 };
3754 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3755
3756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3757 let result = rule.check(&ctx).unwrap();
3758
3759 assert!(
3760 result.is_empty(),
3761 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3762 );
3763 }
3764
3765 #[test]
3768 fn test_absolute_trailing_slash_dir_link_requires_index() {
3769 let temp_dir = tempdir().unwrap();
3770 let root = temp_dir.path();
3771
3772 let dir_d = root.join("d");
3774 std::fs::create_dir_all(&dir_d).unwrap();
3775 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3776
3777 let content = "[dir with slash](/d/)\n";
3779
3780 let config = MD057Config {
3781 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3782 roots: vec![],
3783 ..Default::default()
3784 };
3785 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3786
3787 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3788 let result = rule.check(&ctx).unwrap();
3789
3790 assert_eq!(
3791 result.len(),
3792 1,
3793 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3794 );
3795 }
3796
3797 #[test]
3801 fn test_docs_dir_variant_still_enforces_index_md() {
3802 let temp_dir = tempdir().unwrap();
3803 let root = temp_dir.path();
3804
3805 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3807
3808 let docs_dir = root.join("docs");
3810 std::fs::create_dir_all(&docs_dir).unwrap();
3811 let section_dir = docs_dir.join("section");
3812 std::fs::create_dir_all(§ion_dir).unwrap();
3813 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3814
3815 let source_file = docs_dir.join("index.md");
3817 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3818
3819 let config = MD057Config {
3820 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3821 ..Default::default()
3822 };
3823 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3824
3825 let content = "[sec](/section)\n";
3826 let ctx = crate::lint_context::LintContext::new(
3827 content,
3828 crate::config::MarkdownFlavor::Standard,
3829 Some(source_file.clone()),
3830 );
3831 let result = rule.check(&ctx).unwrap();
3832
3833 assert_eq!(
3835 result.len(),
3836 1,
3837 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3838 );
3839 assert!(
3840 result[0].message.contains("index.md") || result[0].message.contains("section"),
3841 "Message should mention the directory or missing index.md: {}",
3842 result[0].message
3843 );
3844 }
3845
3846 #[test]
3852 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3853 let temp_dir = tempdir().unwrap();
3854 let root = temp_dir.path();
3855
3856 let guide_dir = root.join("guide");
3858 std::fs::create_dir_all(&guide_dir).unwrap();
3859 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3860
3861 let content = "[guide with fragment](/guide/#intro)\n";
3863
3864 let config = MD057Config {
3865 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3866 roots: vec![],
3867 ..Default::default()
3868 };
3869 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3870 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3871 let result = rule.check(&ctx).unwrap();
3872
3873 assert_eq!(
3874 result.len(),
3875 1,
3876 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3877 );
3878 }
3879}