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>>>,
129 config: MD057Config,
131 flavor: crate::config::MarkdownFlavor,
133}
134
135impl Default for MD057ExistingRelativeLinks {
136 fn default() -> Self {
137 Self {
138 base_path: Arc::new(Mutex::new(None)),
139 config: MD057Config::default(),
140 flavor: crate::config::MarkdownFlavor::default(),
141 }
142 }
143}
144
145impl MD057ExistingRelativeLinks {
146 pub fn new() -> Self {
148 Self::default()
149 }
150
151 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
153 let path = path.as_ref();
154 let dir_path = if path.is_file() {
155 path.parent().map(std::path::Path::to_path_buf)
156 } else {
157 Some(path.to_path_buf())
158 };
159
160 if let Ok(mut guard) = self.base_path.lock() {
161 *guard = dir_path;
162 }
163 self
164 }
165
166 pub fn from_config_struct(config: MD057Config) -> Self {
167 Self {
168 base_path: Arc::new(Mutex::new(None)),
169 config,
170 flavor: crate::config::MarkdownFlavor::default(),
171 }
172 }
173
174 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
178 if Path::new(path_str).is_absolute() {
179 PathBuf::from(path_str)
180 } else {
181 project_root.join(path_str)
182 }
183 }
184
185 #[cfg(test)]
187 fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self {
188 self.flavor = flavor;
189 self
190 }
191
192 #[inline]
204 fn is_external_url(&self, url: &str) -> bool {
205 if url.is_empty() {
206 return false;
207 }
208
209 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
211 return true;
212 }
213
214 if url.starts_with("{{") || url.starts_with("{%") {
217 return true;
218 }
219
220 if url.contains('@') {
223 return true; }
225
226 if !url.contains('/') && url.ends_with(".com") {
236 return true;
237 }
238
239 if url.starts_with('~') || url.starts_with('@') {
243 return true;
244 }
245
246 false
248 }
249
250 #[inline]
252 fn is_fragment_only_link(&self, url: &str) -> bool {
253 url.starts_with('#')
254 }
255
256 #[inline]
259 fn is_absolute_path(url: &str) -> bool {
260 url.starts_with('/')
261 }
262
263 fn url_decode(path: &str) -> String {
267 if !path.contains('%') {
269 return path.to_string();
270 }
271
272 let bytes = path.as_bytes();
273 let mut result = Vec::with_capacity(bytes.len());
274 let mut i = 0;
275
276 while i < bytes.len() {
277 if bytes[i] == b'%' && i + 2 < bytes.len() {
278 let hex1 = bytes[i + 1];
280 let hex2 = bytes[i + 2];
281 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
282 result.push(d1 * 16 + d2);
283 i += 3;
284 continue;
285 }
286 }
287 result.push(bytes[i]);
288 i += 1;
289 }
290
291 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
293 }
294
295 fn strip_query_and_fragment(url: &str) -> &str {
303 let query_pos = url.find('?');
306 let fragment_pos = url.find('#');
307
308 match (query_pos, fragment_pos) {
309 (Some(q), Some(f)) => {
310 &url[..q.min(f)]
312 }
313 (Some(q), None) => &url[..q],
314 (None, Some(f)) => &url[..f],
315 (None, None) => url,
316 }
317 }
318
319 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
321 base_path.join(link)
322 }
323
324 fn compute_search_paths(
329 &self,
330 flavor: crate::config::MarkdownFlavor,
331 source_file: Option<&Path>,
332 base_path: &Path,
333 project_root: &Path,
334 ) -> Vec<PathBuf> {
335 let mut paths = Vec::new();
336
337 if flavor == crate::config::MarkdownFlavor::Obsidian
339 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
340 && attachment_dir != *base_path
341 {
342 paths.push(attachment_dir);
343 }
344
345 for search_path in &self.config.search_paths {
349 let resolved = Self::resolve_against_project_root(search_path, project_root);
350 if resolved != *base_path && !paths.contains(&resolved) {
351 paths.push(resolved);
352 }
353 }
354
355 paths
356 }
357
358 fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool {
360 search_paths.iter().any(|dir| {
361 let candidate = dir.join(decoded_path);
362 file_exists_or_markdown_extension(&candidate)
363 })
364 }
365
366 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
372 if !self.config.compact_paths {
373 return None;
374 }
375
376 let path_end = url
378 .find('?')
379 .unwrap_or(url.len())
380 .min(url.find('#').unwrap_or(url.len()));
381 let path_part = &url[..path_end];
382 let suffix = &url[path_end..];
383
384 let decoded_path = Self::url_decode(path_part);
386
387 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
388 }
389
390 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
396 let Some(docs_dir) = resolve_docs_dir(source_path) else {
397 return Some(format!(
398 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
399 ));
400 };
401
402 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
403
404 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, is_directory_link, true) {
407 Resolution::Found => None,
408 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
409 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
410 resolved.display()
411 )),
412 Resolution::NotFound { resolved } => Some(format!(
413 "Absolute link '{url}' resolves to '{}' which does not exist",
414 resolved.display()
415 )),
416 }
417 }
418
419 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
428 let (decoded, is_directory_link) = Self::prepare_absolute_url(url);
429
430 for root in roots {
431 let root_path = Self::resolve_against_project_root(root, project_root);
432 if matches!(
435 Self::resolve_under_root_with_opts(&root_path, &decoded, is_directory_link, false),
436 Resolution::Found
437 ) {
438 return None;
439 }
440 }
441
442 if matches!(
443 Self::resolve_under_root_with_opts(project_root, &decoded, is_directory_link, false),
445 Resolution::Found
446 ) {
447 return None;
448 }
449
450 let msg = if roots.is_empty() {
451 format!("Absolute link '{url}' was not found under the project root")
452 } else {
453 format!("Absolute link '{url}' was not found under any configured root or the project root")
454 };
455 Some(msg)
456 }
457
458 fn prepare_absolute_url(url: &str) -> (String, bool) {
462 let relative_url = url.trim_start_matches('/');
463 let file_path = Self::strip_query_and_fragment(relative_url);
464 let decoded = Self::url_decode(file_path);
465 let is_directory_link = url.ends_with('/') || decoded.is_empty();
466 (decoded, is_directory_link)
467 }
468
469 fn resolve_under_root_with_opts(
491 root_path: &Path,
492 decoded: &str,
493 is_directory_link: bool,
494 require_index_for_dirs: bool,
495 ) -> Resolution {
496 let resolved = root_path.join(decoded);
497
498 let is_dir = resolved.is_dir();
499
500 if is_directory_link || (require_index_for_dirs && is_dir) {
505 let index_path = resolved.join("index.md");
506 if file_exists_with_cache(&index_path) {
507 return Resolution::Found;
508 }
509 if is_dir {
510 return Resolution::DirectoryWithoutIndex { resolved };
511 }
512 }
513
514 let decoded_has_trailing_slash = decoded.ends_with('/');
520 if !require_index_for_dirs && !is_directory_link && !decoded_has_trailing_slash && is_dir {
521 return Resolution::Found;
522 }
523
524 if file_exists_or_markdown_extension(&resolved) {
525 return Resolution::Found;
526 }
527
528 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
531 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
532 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
533 {
534 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
535 let source_path = parent.join(format!("{stem}{md_ext}"));
536 file_exists_with_cache(&source_path)
537 });
538 if has_md_source {
539 return Resolution::Found;
540 }
541 }
542
543 Resolution::NotFound { resolved }
544 }
545}
546
547enum Resolution {
551 Found,
552 DirectoryWithoutIndex { resolved: PathBuf },
553 NotFound { resolved: PathBuf },
554}
555
556fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
567 let caps = re.captures_at(line, expected_start)?;
568 if caps.get(0)?.start() != expected_start {
569 return None;
570 }
571 Some(caps)
572}
573
574impl Rule for MD057ExistingRelativeLinks {
575 fn name(&self) -> &'static str {
576 "MD057"
577 }
578
579 fn description(&self) -> &'static str {
580 "Relative links should point to existing files"
581 }
582
583 fn category(&self) -> RuleCategory {
584 RuleCategory::Link
585 }
586
587 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
588 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
589 }
590
591 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
592 let content = ctx.content;
593
594 if content.is_empty() || !content.contains('[') {
596 return Ok(Vec::new());
597 }
598
599 if !content.contains("](") && !content.contains("]:") {
602 return Ok(Vec::new());
603 }
604
605 reset_file_existence_cache();
607
608 let mut warnings = Vec::new();
609
610 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
614
615 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
619
620 let base_path: Option<PathBuf> = {
624 if explicit_base.is_some() {
625 explicit_base
626 } else if let Some(ref source_file) = ctx.source_file {
627 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
631 resolved_file
632 .parent()
633 .map(std::path::Path::to_path_buf)
634 .or_else(|| Some(CURRENT_DIR.clone()))
635 } else {
636 None
638 }
639 };
640
641 let Some(base_path) = base_path else {
643 return Ok(warnings);
644 };
645
646 let extra_search_paths =
648 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
649
650 if !ctx.links.is_empty() {
652 let line_index = &ctx.line_index;
654
655 let lines = ctx.raw_lines();
657
658 let mut processed_lines = std::collections::HashSet::new();
661
662 for link in &ctx.links {
663 let line_idx = link.line - 1;
664 if line_idx >= lines.len() {
665 continue;
666 }
667
668 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
670 continue;
671 }
672
673 if !processed_lines.insert(line_idx) {
675 continue;
676 }
677
678 let line = lines[line_idx];
679
680 if !line.contains("](") {
682 continue;
683 }
684
685 for link_match in LINK_START_REGEX.find_iter(line) {
687 if link_match.as_str().starts_with('!') {
694 let escapes = line[..link_match.start()]
695 .bytes()
696 .rev()
697 .take_while(|&b| b == b'\\')
698 .count();
699 if escapes % 2 == 0 {
700 continue;
701 }
702 }
703
704 let start_pos = link_match.start();
705 let end_pos = link_match.end();
706
707 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
709 let absolute_start_pos = line_start_byte + start_pos;
710
711 if ctx.is_in_code_span_byte(absolute_start_pos) {
713 continue;
714 }
715
716 if ctx.is_in_math_span(absolute_start_pos) {
718 continue;
719 }
720
721 let caps_and_url = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, line, end_pos - 1)
728 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
729 .or_else(|| {
730 extract_url_at(&URL_EXTRACT_REGEX, line, end_pos - 1)
731 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
732 });
733
734 if let Some((caps, url_group)) = caps_and_url {
735 let url = url_group.as_str().trim();
736
737 if url.is_empty() {
739 continue;
740 }
741
742 if url.starts_with('`') && url.ends_with('`') {
746 continue;
747 }
748
749 if self.is_external_url(url) || self.is_fragment_only_link(url) {
751 continue;
752 }
753
754 if Self::is_absolute_path(url) {
756 match self.config.absolute_links {
757 AbsoluteLinksOption::Warn => {
758 let url_start = url_group.start();
759 let url_end = url_group.end();
760 warnings.push(LintWarning {
761 rule_name: Some(self.name().to_string()),
762 line: link.line,
763 column: byte_to_char_count(line, url_start),
764 end_line: link.line,
765 end_column: byte_to_char_count(line, url_end),
766 message: format!("Absolute link '{url}' cannot be validated locally"),
767 severity: Severity::Warning,
768 fix: None,
769 });
770 }
771 AbsoluteLinksOption::RelativeToDocs => {
772 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
773 let url_start = url_group.start();
774 let url_end = url_group.end();
775 warnings.push(LintWarning {
776 rule_name: Some(self.name().to_string()),
777 line: link.line,
778 column: byte_to_char_count(line, url_start),
779 end_line: link.line,
780 end_column: byte_to_char_count(line, url_end),
781 message: msg,
782 severity: Severity::Warning,
783 fix: None,
784 });
785 }
786 }
787 AbsoluteLinksOption::RelativeToRoots => {
788 if let Some(msg) =
789 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
790 {
791 let url_start = url_group.start();
792 let url_end = url_group.end();
793 warnings.push(LintWarning {
794 rule_name: Some(self.name().to_string()),
795 line: link.line,
796 column: byte_to_char_count(line, url_start),
797 end_line: link.line,
798 end_column: byte_to_char_count(line, url_end),
799 message: msg,
800 severity: Severity::Warning,
801 fix: None,
802 });
803 }
804 }
805 AbsoluteLinksOption::Ignore => {}
806 }
807 continue;
808 }
809
810 let full_url_for_compact = if let Some(frag) = caps.get(2) {
814 format!("{url}{}", frag.as_str())
815 } else {
816 url.to_string()
817 };
818 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
819 let url_start = url_group.start();
820 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
821 let fix_byte_start = line_start_byte + url_start;
822 let fix_byte_end = line_start_byte + url_end;
823 warnings.push(LintWarning {
824 rule_name: Some(self.name().to_string()),
825 line: link.line,
826 column: byte_to_char_count(line, url_start),
827 end_line: link.line,
828 end_column: byte_to_char_count(line, url_end),
829 message: format!(
830 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
831 ),
832 severity: Severity::Warning,
833 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
834 });
835 }
836
837 let file_path = Self::strip_query_and_fragment(url);
839
840 let decoded_path = Self::url_decode(file_path);
842
843 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
845
846 if file_exists_or_markdown_extension(&resolved_path) {
848 continue; }
850
851 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
853 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
854 && let (Some(stem), Some(parent)) = (
855 resolved_path.file_stem().and_then(|s| s.to_str()),
856 resolved_path.parent(),
857 ) {
858 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
859 let source_path = parent.join(format!("{stem}{md_ext}"));
860 file_exists_with_cache(&source_path)
861 })
862 } else {
863 false
864 };
865
866 if has_md_source {
867 continue; }
869
870 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
872 continue;
873 }
874
875 let url_start = url_group.start();
879 let url_end = url_group.end();
880
881 warnings.push(LintWarning {
882 rule_name: Some(self.name().to_string()),
883 line: link.line,
884 column: byte_to_char_count(line, url_start),
885 end_line: link.line,
886 end_column: byte_to_char_count(line, url_end),
887 message: format!("Relative link '{url}' does not exist"),
888 severity: Severity::Error,
889 fix: None,
890 });
891 }
892 }
893 }
894 }
895
896 for image in &ctx.images {
898 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
900 continue;
901 }
902
903 let url = image.url.as_ref();
904
905 if url.is_empty() {
907 continue;
908 }
909
910 if self.is_external_url(url) || self.is_fragment_only_link(url) {
912 continue;
913 }
914
915 if Self::is_absolute_path(url) {
917 match self.config.absolute_links {
918 AbsoluteLinksOption::Warn => {
919 warnings.push(LintWarning {
920 rule_name: Some(self.name().to_string()),
921 line: image.line,
922 column: image.start_col + 1,
923 end_line: image.line,
924 end_column: image.start_col + 1 + url.chars().count(),
925 message: format!("Absolute link '{url}' cannot be validated locally"),
926 severity: Severity::Warning,
927 fix: None,
928 });
929 }
930 AbsoluteLinksOption::RelativeToDocs => {
931 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
932 warnings.push(LintWarning {
933 rule_name: Some(self.name().to_string()),
934 line: image.line,
935 column: image.start_col + 1,
936 end_line: image.line,
937 end_column: image.start_col + 1 + url.chars().count(),
938 message: msg,
939 severity: Severity::Warning,
940 fix: None,
941 });
942 }
943 }
944 AbsoluteLinksOption::RelativeToRoots => {
945 if let Some(msg) =
946 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
947 {
948 warnings.push(LintWarning {
949 rule_name: Some(self.name().to_string()),
950 line: image.line,
951 column: image.start_col + 1,
952 end_line: image.line,
953 end_column: image.start_col + 1 + url.chars().count(),
954 message: msg,
955 severity: Severity::Warning,
956 fix: None,
957 });
958 }
959 }
960 AbsoluteLinksOption::Ignore => {}
961 }
962 continue;
963 }
964
965 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
967 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
970 let fix_byte_start = image.byte_offset + url_offset;
971 let fix_byte_end = fix_byte_start + url.len();
972 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
973 });
974
975 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
976 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
977 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
980 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
981 });
982 warnings.push(LintWarning {
983 rule_name: Some(self.name().to_string()),
984 line: image.line,
985 column: url_col,
986 end_line: image.line,
987 end_column: url_col + url.chars().count(),
988 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
989 severity: Severity::Warning,
990 fix,
991 });
992 }
993
994 let file_path = Self::strip_query_and_fragment(url);
996
997 let decoded_path = Self::url_decode(file_path);
999
1000 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1002
1003 if file_exists_or_markdown_extension(&resolved_path) {
1005 continue; }
1007
1008 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1010 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1011 && let (Some(stem), Some(parent)) = (
1012 resolved_path.file_stem().and_then(|s| s.to_str()),
1013 resolved_path.parent(),
1014 ) {
1015 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1016 let source_path = parent.join(format!("{stem}{md_ext}"));
1017 file_exists_with_cache(&source_path)
1018 })
1019 } else {
1020 false
1021 };
1022
1023 if has_md_source {
1024 continue; }
1026
1027 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1029 continue;
1030 }
1031
1032 warnings.push(LintWarning {
1035 rule_name: Some(self.name().to_string()),
1036 line: image.line,
1037 column: image.start_col + 1,
1038 end_line: image.line,
1039 end_column: image.start_col + 1 + url.chars().count(),
1040 message: format!("Relative link '{url}' does not exist"),
1041 severity: Severity::Error,
1042 fix: None,
1043 });
1044 }
1045
1046 for ref_def in &ctx.reference_defs {
1048 let url = &ref_def.url;
1049
1050 if url.is_empty() {
1052 continue;
1053 }
1054
1055 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1057 continue;
1058 }
1059
1060 if Self::is_absolute_path(url) {
1062 match self.config.absolute_links {
1063 AbsoluteLinksOption::Warn => {
1064 let line_idx = ref_def.line - 1;
1065 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1066 line_content
1067 .find(url.as_str())
1068 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1069 });
1070 warnings.push(LintWarning {
1071 rule_name: Some(self.name().to_string()),
1072 line: ref_def.line,
1073 column,
1074 end_line: ref_def.line,
1075 end_column: column + url.chars().count(),
1076 message: format!("Absolute link '{url}' cannot be validated locally"),
1077 severity: Severity::Warning,
1078 fix: None,
1079 });
1080 }
1081 AbsoluteLinksOption::RelativeToDocs => {
1082 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
1083 let line_idx = ref_def.line - 1;
1084 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1085 line_content
1086 .find(url.as_str())
1087 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1088 });
1089 warnings.push(LintWarning {
1090 rule_name: Some(self.name().to_string()),
1091 line: ref_def.line,
1092 column,
1093 end_line: ref_def.line,
1094 end_column: column + url.chars().count(),
1095 message: msg,
1096 severity: Severity::Warning,
1097 fix: None,
1098 });
1099 }
1100 }
1101 AbsoluteLinksOption::RelativeToRoots => {
1102 if let Some(msg) =
1103 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
1104 {
1105 let line_idx = ref_def.line - 1;
1106 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1107 line_content
1108 .find(url.as_str())
1109 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1110 });
1111 warnings.push(LintWarning {
1112 rule_name: Some(self.name().to_string()),
1113 line: ref_def.line,
1114 column,
1115 end_line: ref_def.line,
1116 end_column: column + url.chars().count(),
1117 message: msg,
1118 severity: Severity::Warning,
1119 fix: None,
1120 });
1121 }
1122 }
1123 AbsoluteLinksOption::Ignore => {}
1124 }
1125 continue;
1126 }
1127
1128 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1130 let ref_line_idx = ref_def.line - 1;
1131 let line_content = ctx.raw_lines().get(ref_line_idx).copied().unwrap_or("");
1132 let url_byte = line_content.find(url.as_str());
1135 let col = url_byte.map_or(1, |b| byte_to_char_count(line_content, b));
1136 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1137 let fix_byte_start = ref_line_start_byte + url_byte.unwrap_or(0);
1138 let fix_byte_end = fix_byte_start + url.len();
1139 warnings.push(LintWarning {
1140 rule_name: Some(self.name().to_string()),
1141 line: ref_def.line,
1142 column: col,
1143 end_line: ref_def.line,
1144 end_column: col + url.chars().count(),
1145 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1146 severity: Severity::Warning,
1147 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1148 });
1149 }
1150
1151 let file_path = Self::strip_query_and_fragment(url);
1153
1154 let decoded_path = Self::url_decode(file_path);
1156
1157 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1159
1160 if file_exists_or_markdown_extension(&resolved_path) {
1162 continue; }
1164
1165 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1167 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1168 && let (Some(stem), Some(parent)) = (
1169 resolved_path.file_stem().and_then(|s| s.to_str()),
1170 resolved_path.parent(),
1171 ) {
1172 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1173 let source_path = parent.join(format!("{stem}{md_ext}"));
1174 file_exists_with_cache(&source_path)
1175 })
1176 } else {
1177 false
1178 };
1179
1180 if has_md_source {
1181 continue; }
1183
1184 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1186 continue;
1187 }
1188
1189 let line_idx = ref_def.line - 1;
1192 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1193 line_content
1195 .find(url.as_str())
1196 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1197 });
1198
1199 warnings.push(LintWarning {
1200 rule_name: Some(self.name().to_string()),
1201 line: ref_def.line,
1202 column,
1203 end_line: ref_def.line,
1204 end_column: column + url.chars().count(),
1205 message: format!("Relative link '{url}' does not exist"),
1206 severity: Severity::Error,
1207 fix: None,
1208 });
1209 }
1210
1211 Ok(warnings)
1212 }
1213
1214 fn fix_capability(&self) -> FixCapability {
1215 if self.config.compact_paths {
1216 FixCapability::ConditionallyFixable
1217 } else {
1218 FixCapability::Unfixable
1219 }
1220 }
1221
1222 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1223 if !self.config.compact_paths {
1224 return Ok(ctx.content.to_string());
1225 }
1226
1227 let warnings = self.check(ctx)?;
1228 let warnings =
1229 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1230 let mut content = ctx.content.to_string();
1231
1232 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1234 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1235
1236 let mut last_applied_start: Option<usize> = None;
1242 for fix in fixes {
1243 if let Some(prev_start) = last_applied_start
1244 && fix.range.end > prev_start
1245 {
1246 continue;
1247 }
1248 if fix.range.end <= content.len() {
1249 content.replace_range(fix.range.clone(), &fix.replacement);
1250 last_applied_start = Some(fix.range.start);
1251 }
1252 }
1253
1254 Ok(content)
1255 }
1256
1257 fn as_any(&self) -> &dyn std::any::Any {
1258 self
1259 }
1260
1261 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1262 let default_config = MD057Config::default();
1263 let json_value = serde_json::to_value(&default_config).ok()?;
1264 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1265
1266 if let toml::Value::Table(table) = toml_value {
1267 if !table.is_empty() {
1268 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1269 } else {
1270 None
1271 }
1272 } else {
1273 None
1274 }
1275 }
1276
1277 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1278 where
1279 Self: Sized,
1280 {
1281 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1282 let mut rule = Self::from_config_struct(rule_config);
1283 rule.flavor = config.global.flavor;
1284 Box::new(rule)
1285 }
1286
1287 fn cross_file_scope(&self) -> CrossFileScope {
1288 CrossFileScope::Workspace
1289 }
1290
1291 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1292 let links = extract_cross_file_links(ctx);
1295 for link in links.relative {
1296 index.add_cross_file_link(link);
1297 }
1298 for link in links.root_relative {
1301 index.add_root_relative_link(link);
1302 }
1303 }
1304
1305 fn cross_file_check(
1306 &self,
1307 _file_path: &Path,
1308 _file_index: &FileIndex,
1309 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1310 ) -> LintResult {
1311 Ok(Vec::new())
1321 }
1322}
1323
1324fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1329 let from_components: Vec<_> = from_dir.components().collect();
1330 let to_components: Vec<_> = to_path.components().collect();
1331
1332 let common_len = from_components
1334 .iter()
1335 .zip(to_components.iter())
1336 .take_while(|(a, b)| a == b)
1337 .count();
1338
1339 let mut result = PathBuf::new();
1340
1341 for _ in common_len..from_components.len() {
1343 result.push("..");
1344 }
1345
1346 for component in &to_components[common_len..] {
1348 result.push(component);
1349 }
1350
1351 result
1352}
1353
1354fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1360 let link_path = Path::new(raw_link_path);
1361
1362 let has_traversal = link_path
1364 .components()
1365 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1366
1367 if !has_traversal {
1368 return None;
1369 }
1370
1371 let combined = source_dir.join(link_path);
1373 let normalized_target = normalize_path(&combined);
1374
1375 let normalized_source = normalize_path(source_dir);
1377 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1378
1379 if shortest != link_path {
1381 let compact = shortest.to_string_lossy().to_string();
1382 if compact.is_empty() {
1384 return None;
1385 }
1386 Some(compact.replace('\\', "/"))
1388 } else {
1389 None
1390 }
1391}
1392
1393fn normalize_path(path: &Path) -> PathBuf {
1395 let mut components = Vec::new();
1396
1397 for component in path.components() {
1398 match component {
1399 std::path::Component::ParentDir => {
1400 if !components.is_empty() {
1402 components.pop();
1403 }
1404 }
1405 std::path::Component::CurDir => {
1406 }
1408 _ => {
1409 components.push(component);
1410 }
1411 }
1412 }
1413
1414 components.iter().collect()
1415}
1416
1417#[cfg(test)]
1418mod tests {
1419 use super::*;
1420 use crate::workspace_index::CrossFileLinkIndex;
1421 use std::fs::File;
1422 use std::io::Write;
1423 use tempfile::tempdir;
1424
1425 #[test]
1426 fn test_strip_query_and_fragment() {
1427 assert_eq!(
1429 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1430 "file.png"
1431 );
1432 assert_eq!(
1433 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1434 "file.png"
1435 );
1436 assert_eq!(
1437 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1438 "file.png"
1439 );
1440
1441 assert_eq!(
1443 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1444 "file.md"
1445 );
1446 assert_eq!(
1447 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1448 "file.md"
1449 );
1450
1451 assert_eq!(
1453 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1454 "file.md"
1455 );
1456
1457 assert_eq!(
1459 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1460 "file.png"
1461 );
1462
1463 assert_eq!(
1465 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1466 "path/to/image.png"
1467 );
1468 assert_eq!(
1469 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1470 "path/to/image.png"
1471 );
1472
1473 assert_eq!(
1475 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1476 "file.md"
1477 );
1478 }
1479
1480 #[test]
1481 fn test_url_decode() {
1482 assert_eq!(
1484 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1485 "penguin with space.jpg"
1486 );
1487
1488 assert_eq!(
1490 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1491 "assets/my file name.png"
1492 );
1493
1494 assert_eq!(
1496 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1497 "hello world!.md"
1498 );
1499
1500 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1502
1503 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1505
1506 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1508
1509 assert_eq!(
1511 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1512 "normal-file.md"
1513 );
1514
1515 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1517
1518 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1520
1521 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1523
1524 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1526
1527 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1529
1530 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1532
1533 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1535
1536 assert_eq!(
1538 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1539 "path/to/file.md"
1540 );
1541
1542 assert_eq!(
1544 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1545 "hello world/foo bar.md"
1546 );
1547
1548 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1550
1551 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1553 }
1554
1555 #[test]
1556 fn test_url_encoded_filenames() {
1557 let temp_dir = tempdir().unwrap();
1559 let base_path = temp_dir.path();
1560
1561 let file_with_spaces = base_path.join("penguin with space.jpg");
1563 File::create(&file_with_spaces)
1564 .unwrap()
1565 .write_all(b"image data")
1566 .unwrap();
1567
1568 let subdir = base_path.join("my images");
1570 std::fs::create_dir(&subdir).unwrap();
1571 let nested_file = subdir.join("photo 1.png");
1572 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1573
1574 let content = r#"
1576# Test Document with URL-Encoded Links
1577
1578
1579
1580
1581"#;
1582
1583 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1584
1585 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1586 let result = rule.check(&ctx).unwrap();
1587
1588 assert_eq!(
1590 result.len(),
1591 1,
1592 "Should only warn about missing%20file.jpg. Got: {result:?}"
1593 );
1594 assert!(
1595 result[0].message.contains("missing%20file.jpg"),
1596 "Warning should mention the URL-encoded filename"
1597 );
1598 }
1599
1600 #[test]
1601 fn test_external_urls() {
1602 let rule = MD057ExistingRelativeLinks::new();
1603
1604 assert!(rule.is_external_url("https://example.com"));
1606 assert!(rule.is_external_url("http://example.com"));
1607 assert!(rule.is_external_url("ftp://example.com"));
1608 assert!(rule.is_external_url("www.example.com"));
1609 assert!(rule.is_external_url("example.com"));
1610
1611 assert!(rule.is_external_url("file:///path/to/file"));
1613 assert!(rule.is_external_url("smb://server/share"));
1614 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1615 assert!(rule.is_external_url("mailto:user@example.com"));
1616 assert!(rule.is_external_url("tel:+1234567890"));
1617 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1618 assert!(rule.is_external_url("javascript:void(0)"));
1619 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1620 assert!(rule.is_external_url("git://github.com/repo.git"));
1621
1622 assert!(rule.is_external_url("user@example.com"));
1625 assert!(rule.is_external_url("steering@kubernetes.io"));
1626 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1627 assert!(rule.is_external_url("user_name@sub.domain.com"));
1628 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1629
1630 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"));
1641 assert!(!rule.is_external_url("/blog/2024/release.html"));
1642 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1643 assert!(!rule.is_external_url("/pkg/runtime"));
1644 assert!(!rule.is_external_url("/doc/go1compat"));
1645 assert!(!rule.is_external_url("/index.html"));
1646 assert!(!rule.is_external_url("/assets/logo.png"));
1647
1648 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1650 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1651 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1652 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1653 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1654
1655 assert!(rule.is_external_url("~/assets/image.png"));
1658 assert!(rule.is_external_url("~/components/Button.vue"));
1659 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1663 assert!(rule.is_external_url("@images/photo.jpg"));
1664 assert!(rule.is_external_url("@assets/styles.css"));
1665
1666 assert!(!rule.is_external_url("./relative/path.md"));
1668 assert!(!rule.is_external_url("relative/path.md"));
1669 assert!(!rule.is_external_url("../parent/path.md"));
1670 }
1671
1672 #[test]
1673 fn test_dot_com_only_skips_bare_domains() {
1674 let rule = MD057ExistingRelativeLinks::new();
1675
1676 assert!(rule.is_external_url("example.com"));
1678 assert!(rule.is_external_url("sub.example.com"));
1679
1680 assert!(!rule.is_external_url("../../vendor.com"));
1684 assert!(!rule.is_external_url("./vendor.com"));
1685 assert!(!rule.is_external_url("docs/vendor.com"));
1686 }
1687
1688 #[test]
1689 fn test_framework_path_aliases() {
1690 let temp_dir = tempdir().unwrap();
1692 let base_path = temp_dir.path();
1693
1694 let content = r#"
1696# Framework Path Aliases
1697
1698
1699
1700
1701
1702[Link](@/pages/about.md)
1703
1704This is a [real missing link](missing.md) that should be flagged.
1705"#;
1706
1707 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1708
1709 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1710 let result = rule.check(&ctx).unwrap();
1711
1712 assert_eq!(
1714 result.len(),
1715 1,
1716 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1717 );
1718 assert!(
1719 result[0].message.contains("missing.md"),
1720 "Warning should be for missing.md"
1721 );
1722 }
1723
1724 #[test]
1725 fn test_url_decode_security_path_traversal() {
1726 let temp_dir = tempdir().unwrap();
1729 let base_path = temp_dir.path();
1730
1731 let file_in_base = base_path.join("safe.md");
1733 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1734
1735 let content = r#"
1740[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1741[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1742[Safe link](safe.md)
1743"#;
1744
1745 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1746
1747 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1748 let result = rule.check(&ctx).unwrap();
1749
1750 assert_eq!(
1753 result.len(),
1754 2,
1755 "Should have warnings for traversal attempts. Got: {result:?}"
1756 );
1757 }
1758
1759 #[test]
1760 fn test_url_encoded_utf8_filenames() {
1761 let temp_dir = tempdir().unwrap();
1763 let base_path = temp_dir.path();
1764
1765 let cafe_file = base_path.join("café.md");
1767 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1768
1769 let content = r#"
1770[Café link](caf%C3%A9.md)
1771[Missing unicode](r%C3%A9sum%C3%A9.md)
1772"#;
1773
1774 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1775
1776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1777 let result = rule.check(&ctx).unwrap();
1778
1779 assert_eq!(
1781 result.len(),
1782 1,
1783 "Should only warn about missing résumé.md. Got: {result:?}"
1784 );
1785 assert!(
1786 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1787 "Warning should mention the URL-encoded filename"
1788 );
1789 }
1790
1791 #[test]
1792 fn test_url_encoded_emoji_filenames() {
1793 let temp_dir = tempdir().unwrap();
1796 let base_path = temp_dir.path();
1797
1798 let emoji_dir = base_path.join("👤 Personal");
1800 std::fs::create_dir(&emoji_dir).unwrap();
1801
1802 let file_path = emoji_dir.join("TV Shows.md");
1804 File::create(&file_path)
1805 .unwrap()
1806 .write_all(b"# TV Shows\n\nContent here.")
1807 .unwrap();
1808
1809 let content = r#"
1812# Test Document
1813
1814[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1815[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1816"#;
1817
1818 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1819
1820 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1821 let result = rule.check(&ctx).unwrap();
1822
1823 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1825 assert!(
1826 result[0].message.contains("Missing.md"),
1827 "Warning should be for Missing.md, got: {}",
1828 result[0].message
1829 );
1830 }
1831
1832 #[test]
1833 fn test_no_warnings_without_base_path() {
1834 let rule = MD057ExistingRelativeLinks::new();
1835 let content = "[Link](missing.md)";
1836
1837 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1838 let result = rule.check(&ctx).unwrap();
1839 assert!(result.is_empty(), "Should have no warnings without base path");
1840 }
1841
1842 #[test]
1843 fn test_existing_and_missing_links() {
1844 let temp_dir = tempdir().unwrap();
1846 let base_path = temp_dir.path();
1847
1848 let exists_path = base_path.join("exists.md");
1850 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1851
1852 assert!(exists_path.exists(), "exists.md should exist for this test");
1854
1855 let content = r#"
1857# Test Document
1858
1859[Valid Link](exists.md)
1860[Invalid Link](missing.md)
1861[External Link](https://example.com)
1862[Media Link](image.jpg)
1863 "#;
1864
1865 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1867
1868 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1870 let result = rule.check(&ctx).unwrap();
1871
1872 assert_eq!(result.len(), 2);
1874 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1875 assert!(messages.iter().any(|m| m.contains("missing.md")));
1876 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1877 }
1878
1879 #[test]
1880 fn test_angle_bracket_links() {
1881 let temp_dir = tempdir().unwrap();
1883 let base_path = temp_dir.path();
1884
1885 let exists_path = base_path.join("exists.md");
1887 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1888
1889 let content = r#"
1891# Test Document
1892
1893[Valid Link](<exists.md>)
1894[Invalid Link](<missing.md>)
1895[External Link](<https://example.com>)
1896 "#;
1897
1898 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1900
1901 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1902 let result = rule.check(&ctx).unwrap();
1903
1904 assert_eq!(result.len(), 1, "Should have exactly one warning");
1906 assert!(
1907 result[0].message.contains("missing.md"),
1908 "Warning should mention missing.md"
1909 );
1910 }
1911
1912 #[test]
1913 fn test_angle_bracket_links_with_parens() {
1914 let temp_dir = tempdir().unwrap();
1916 let base_path = temp_dir.path();
1917
1918 let app_dir = base_path.join("app");
1920 std::fs::create_dir(&app_dir).unwrap();
1921 let upload_dir = app_dir.join("(upload)");
1922 std::fs::create_dir(&upload_dir).unwrap();
1923 let page_file = upload_dir.join("page.tsx");
1924 File::create(&page_file)
1925 .unwrap()
1926 .write_all(b"export default function Page() {}")
1927 .unwrap();
1928
1929 let content = r#"
1931# Test Document with Paths Containing Parens
1932
1933[Upload Page](<app/(upload)/page.tsx>)
1934[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1935[Missing](<app/(missing)/file.md>)
1936"#;
1937
1938 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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!(
1945 result.len(),
1946 1,
1947 "Should have exactly one warning for missing file. Got: {result:?}"
1948 );
1949 assert!(
1950 result[0].message.contains("app/(missing)/file.md"),
1951 "Warning should mention app/(missing)/file.md"
1952 );
1953 }
1954
1955 #[test]
1956 fn test_all_file_types_checked() {
1957 let temp_dir = tempdir().unwrap();
1959 let base_path = temp_dir.path();
1960
1961 let content = r#"
1963[Image Link](image.jpg)
1964[Video Link](video.mp4)
1965[Markdown Link](document.md)
1966[PDF Link](file.pdf)
1967"#;
1968
1969 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1970
1971 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1972 let result = rule.check(&ctx).unwrap();
1973
1974 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1976 }
1977
1978 #[test]
1979 fn test_code_span_detection() {
1980 let rule = MD057ExistingRelativeLinks::new();
1981
1982 let temp_dir = tempdir().unwrap();
1984 let base_path = temp_dir.path();
1985
1986 let rule = rule.with_path(base_path);
1987
1988 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1990
1991 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1992 let result = rule.check(&ctx).unwrap();
1993
1994 assert_eq!(result.len(), 1, "Should only flag the real link");
1996 assert!(result[0].message.contains("nonexistent.md"));
1997 }
1998
1999 #[test]
2000 fn test_inline_code_spans() {
2001 let temp_dir = tempdir().unwrap();
2003 let base_path = temp_dir.path();
2004
2005 let content = r#"
2007# Test Document
2008
2009This is a normal link: [Link](missing.md)
2010
2011This is a code span with a link: `[Link](another-missing.md)`
2012
2013Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2014
2015 "#;
2016
2017 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2019
2020 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2022 let result = rule.check(&ctx).unwrap();
2023
2024 assert_eq!(result.len(), 1, "Should have exactly one warning");
2026 assert!(
2027 result[0].message.contains("missing.md"),
2028 "Warning should be for missing.md"
2029 );
2030 assert!(
2031 !result.iter().any(|w| w.message.contains("another-missing.md")),
2032 "Should not warn about link in code span"
2033 );
2034 assert!(
2035 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2036 "Should not warn about link in inline code"
2037 );
2038 }
2039
2040 #[test]
2041 fn test_extensionless_link_resolution() {
2042 let temp_dir = tempdir().unwrap();
2044 let base_path = temp_dir.path();
2045
2046 let page_path = base_path.join("page.md");
2048 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2049
2050 let content = r#"
2052# Test Document
2053
2054[Link without extension](page)
2055[Link with extension](page.md)
2056[Missing link](nonexistent)
2057"#;
2058
2059 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2060
2061 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2062 let result = rule.check(&ctx).unwrap();
2063
2064 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2067 assert!(
2068 result[0].message.contains("nonexistent"),
2069 "Warning should be for 'nonexistent' not 'page'"
2070 );
2071 }
2072
2073 #[test]
2075 fn test_cross_file_scope() {
2076 let rule = MD057ExistingRelativeLinks::new();
2077 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2078 }
2079
2080 #[test]
2081 fn test_contribute_to_index_extracts_markdown_links() {
2082 let rule = MD057ExistingRelativeLinks::new();
2083 let content = r#"
2084# Document
2085
2086[Link to docs](./docs/guide.md)
2087[Link with fragment](./other.md#section)
2088[External link](https://example.com)
2089[Image link](image.png)
2090[Media file](video.mp4)
2091"#;
2092
2093 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2094 let mut index = FileIndex::new();
2095 rule.contribute_to_index(&ctx, &mut index);
2096
2097 assert_eq!(index.cross_file_links.len(), 2);
2099
2100 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2102 assert_eq!(index.cross_file_links[0].fragment, "");
2103
2104 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2106 assert_eq!(index.cross_file_links[1].fragment, "section");
2107 }
2108
2109 #[test]
2110 fn test_contribute_to_index_skips_external_and_anchors() {
2111 let rule = MD057ExistingRelativeLinks::new();
2112 let content = r#"
2113# Document
2114
2115[External](https://example.com)
2116[Another external](http://example.org)
2117[Fragment only](#section)
2118[FTP link](ftp://files.example.com)
2119[Mail link](mailto:test@example.com)
2120[WWW link](www.example.com)
2121"#;
2122
2123 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124 let mut index = FileIndex::new();
2125 rule.contribute_to_index(&ctx, &mut index);
2126
2127 assert_eq!(index.cross_file_links.len(), 0);
2129 }
2130
2131 #[test]
2132 fn test_cross_file_check_valid_link() {
2133 use crate::workspace_index::WorkspaceIndex;
2134
2135 let rule = MD057ExistingRelativeLinks::new();
2136
2137 let mut workspace_index = WorkspaceIndex::new();
2139 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2140
2141 let mut file_index = FileIndex::new();
2143 file_index.add_cross_file_link(CrossFileLinkIndex {
2144 target_path: "guide.md".to_string(),
2145 fragment: "".to_string(),
2146 line: 5,
2147 column: 1,
2148 });
2149
2150 let warnings = rule
2152 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2153 .unwrap();
2154
2155 assert!(warnings.is_empty());
2157 }
2158
2159 #[test]
2160 fn test_cross_file_check_missing_link() {
2161 use crate::workspace_index::WorkspaceIndex;
2164
2165 let rule = MD057ExistingRelativeLinks::new();
2166 let workspace_index = WorkspaceIndex::new();
2167
2168 let mut file_index = FileIndex::new();
2169 file_index.add_cross_file_link(CrossFileLinkIndex {
2170 target_path: "missing.md".to_string(),
2171 fragment: "".to_string(),
2172 line: 5,
2173 column: 1,
2174 });
2175
2176 let warnings = rule
2177 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2178 .unwrap();
2179
2180 assert!(
2182 warnings.is_empty(),
2183 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_cross_file_check_parent_path() {
2189 use crate::workspace_index::WorkspaceIndex;
2190
2191 let rule = MD057ExistingRelativeLinks::new();
2192
2193 let mut workspace_index = WorkspaceIndex::new();
2195 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2196
2197 let mut file_index = FileIndex::new();
2199 file_index.add_cross_file_link(CrossFileLinkIndex {
2200 target_path: "../readme.md".to_string(),
2201 fragment: "".to_string(),
2202 line: 5,
2203 column: 1,
2204 });
2205
2206 let warnings = rule
2208 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2209 .unwrap();
2210
2211 assert!(warnings.is_empty());
2213 }
2214
2215 #[test]
2216 fn test_cross_file_check_html_link_with_md_source() {
2217 use crate::workspace_index::WorkspaceIndex;
2220
2221 let rule = MD057ExistingRelativeLinks::new();
2222
2223 let mut workspace_index = WorkspaceIndex::new();
2225 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2226
2227 let mut file_index = FileIndex::new();
2229 file_index.add_cross_file_link(CrossFileLinkIndex {
2230 target_path: "guide.html".to_string(),
2231 fragment: "section".to_string(),
2232 line: 10,
2233 column: 5,
2234 });
2235
2236 let warnings = rule
2238 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2239 .unwrap();
2240
2241 assert!(
2243 warnings.is_empty(),
2244 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2245 );
2246 }
2247
2248 #[test]
2249 fn test_cross_file_check_html_link_without_source() {
2250 use crate::workspace_index::WorkspaceIndex;
2254
2255 let rule = MD057ExistingRelativeLinks::new();
2256 let workspace_index = WorkspaceIndex::new();
2257
2258 let mut file_index = FileIndex::new();
2259 file_index.add_cross_file_link(CrossFileLinkIndex {
2260 target_path: "missing.html".to_string(),
2261 fragment: "".to_string(),
2262 line: 10,
2263 column: 5,
2264 });
2265
2266 let warnings = rule
2267 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2268 .unwrap();
2269
2270 assert!(
2272 warnings.is_empty(),
2273 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2274 );
2275 }
2276
2277 #[test]
2278 fn test_normalize_path_function() {
2279 assert_eq!(
2281 normalize_path(Path::new("docs/guide.md")),
2282 PathBuf::from("docs/guide.md")
2283 );
2284
2285 assert_eq!(
2287 normalize_path(Path::new("./docs/guide.md")),
2288 PathBuf::from("docs/guide.md")
2289 );
2290
2291 assert_eq!(
2293 normalize_path(Path::new("docs/sub/../guide.md")),
2294 PathBuf::from("docs/guide.md")
2295 );
2296
2297 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2299 }
2300
2301 #[test]
2302 fn test_html_link_with_md_source() {
2303 let temp_dir = tempdir().unwrap();
2305 let base_path = temp_dir.path();
2306
2307 let md_file = base_path.join("guide.md");
2309 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2310
2311 let content = r#"
2312[Read the guide](guide.html)
2313[Also here](getting-started.html)
2314"#;
2315
2316 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2317 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2318 let result = rule.check(&ctx).unwrap();
2319
2320 assert_eq!(
2322 result.len(),
2323 1,
2324 "Should only warn about missing source. Got: {result:?}"
2325 );
2326 assert!(result[0].message.contains("getting-started.html"));
2327 }
2328
2329 #[test]
2330 fn test_htm_link_with_md_source() {
2331 let temp_dir = tempdir().unwrap();
2333 let base_path = temp_dir.path();
2334
2335 let md_file = base_path.join("page.md");
2336 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2337
2338 let content = "[Page](page.htm)";
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!(
2345 result.is_empty(),
2346 "Should not warn when .md source exists for .htm link"
2347 );
2348 }
2349
2350 #[test]
2351 fn test_html_link_finds_various_markdown_extensions() {
2352 let temp_dir = tempdir().unwrap();
2354 let base_path = temp_dir.path();
2355
2356 File::create(base_path.join("doc.md")).unwrap();
2357 File::create(base_path.join("tutorial.mdx")).unwrap();
2358 File::create(base_path.join("guide.markdown")).unwrap();
2359
2360 let content = r#"
2361[Doc](doc.html)
2362[Tutorial](tutorial.html)
2363[Guide](guide.html)
2364"#;
2365
2366 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2367 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2368 let result = rule.check(&ctx).unwrap();
2369
2370 assert!(
2371 result.is_empty(),
2372 "Should find all markdown variants as source files. Got: {result:?}"
2373 );
2374 }
2375
2376 #[test]
2377 fn test_html_link_in_subdirectory() {
2378 let temp_dir = tempdir().unwrap();
2380 let base_path = temp_dir.path();
2381
2382 let docs_dir = base_path.join("docs");
2383 std::fs::create_dir(&docs_dir).unwrap();
2384 File::create(docs_dir.join("guide.md"))
2385 .unwrap()
2386 .write_all(b"# Guide")
2387 .unwrap();
2388
2389 let content = "[Guide](docs/guide.html)";
2390
2391 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2392 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2393 let result = rule.check(&ctx).unwrap();
2394
2395 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2396 }
2397
2398 #[test]
2399 fn test_absolute_path_skipped_in_check() {
2400 let temp_dir = tempdir().unwrap();
2403 let base_path = temp_dir.path();
2404
2405 let content = r#"
2406# Test Document
2407
2408[Go Runtime](/pkg/runtime)
2409[Go Runtime with Fragment](/pkg/runtime#section)
2410[API Docs](/api/v1/users)
2411[Blog Post](/blog/2024/release.html)
2412[React Hook](/react/hooks/use-state.html)
2413"#;
2414
2415 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2416 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2417 let result = rule.check(&ctx).unwrap();
2418
2419 assert!(
2421 result.is_empty(),
2422 "Absolute paths should be skipped. Got warnings: {result:?}"
2423 );
2424 }
2425
2426 #[test]
2427 fn test_absolute_path_skipped_in_cross_file_check() {
2428 use crate::workspace_index::WorkspaceIndex;
2430
2431 let rule = MD057ExistingRelativeLinks::new();
2432
2433 let workspace_index = WorkspaceIndex::new();
2435
2436 let mut file_index = FileIndex::new();
2438 file_index.add_cross_file_link(CrossFileLinkIndex {
2439 target_path: "/pkg/runtime.md".to_string(),
2440 fragment: "".to_string(),
2441 line: 5,
2442 column: 1,
2443 });
2444 file_index.add_cross_file_link(CrossFileLinkIndex {
2445 target_path: "/api/v1/users.md".to_string(),
2446 fragment: "section".to_string(),
2447 line: 10,
2448 column: 1,
2449 });
2450
2451 let warnings = rule
2453 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2454 .unwrap();
2455
2456 assert!(
2458 warnings.is_empty(),
2459 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2460 );
2461 }
2462
2463 #[test]
2464 fn test_protocol_relative_url_not_skipped() {
2465 let temp_dir = tempdir().unwrap();
2468 let base_path = temp_dir.path();
2469
2470 let content = r#"
2471# Test Document
2472
2473[External](//example.com/page)
2474[Another](//cdn.example.com/asset.js)
2475"#;
2476
2477 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2478 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2479 let result = rule.check(&ctx).unwrap();
2480
2481 assert!(
2483 result.is_empty(),
2484 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2485 );
2486 }
2487
2488 #[test]
2489 fn test_email_addresses_skipped() {
2490 let temp_dir = tempdir().unwrap();
2493 let base_path = temp_dir.path();
2494
2495 let content = r#"
2496# Test Document
2497
2498[Contact](user@example.com)
2499[Steering](steering@kubernetes.io)
2500[Support](john.doe+filter@company.co.uk)
2501[User](user_name@sub.domain.com)
2502"#;
2503
2504 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2505 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2506 let result = rule.check(&ctx).unwrap();
2507
2508 assert!(
2510 result.is_empty(),
2511 "Email addresses should be skipped. Got warnings: {result:?}"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_email_addresses_vs_file_paths() {
2517 let temp_dir = tempdir().unwrap();
2520 let base_path = temp_dir.path();
2521
2522 let content = r#"
2523# Test Document
2524
2525[Email](user@example.com) <!-- Should be skipped (email) -->
2526[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2527[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2528"#;
2529
2530 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2532 let result = rule.check(&ctx).unwrap();
2533
2534 assert!(
2536 result.is_empty(),
2537 "All email addresses should be skipped. Got: {result:?}"
2538 );
2539 }
2540
2541 #[test]
2542 fn test_diagnostic_position_accuracy() {
2543 let temp_dir = tempdir().unwrap();
2545 let base_path = temp_dir.path();
2546
2547 let content = "prefix [text](missing.md) suffix";
2550 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2554 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2555 let result = rule.check(&ctx).unwrap();
2556
2557 assert_eq!(result.len(), 1, "Should have exactly one warning");
2558 assert_eq!(result[0].line, 1, "Should be on line 1");
2559 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2560 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2561 }
2562
2563 #[test]
2564 fn test_diagnostic_position_non_ascii_link() {
2565 let temp_dir = tempdir().unwrap();
2568 let base_path = temp_dir.path();
2569
2570 let content = "你好你好[你好](not-exist.md) bar";
2574
2575 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2576 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2577 let result = rule.check(&ctx).unwrap();
2578
2579 assert_eq!(result.len(), 1, "Should have exactly one warning");
2580 assert_eq!(result[0].line, 1, "Should be on line 1");
2581 assert_eq!(
2582 result[0].column, 10,
2583 "Column must be a character offset, not a byte offset"
2584 );
2585 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2586 }
2587
2588 #[test]
2589 fn test_diagnostic_position_angle_brackets() {
2590 let temp_dir = tempdir().unwrap();
2592 let base_path = temp_dir.path();
2593
2594 let content = "[link](<missing.md>)";
2597 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2600 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2601 let result = rule.check(&ctx).unwrap();
2602
2603 assert_eq!(result.len(), 1, "Should have exactly one warning");
2604 assert_eq!(result[0].line, 1, "Should be on line 1");
2605 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2606 }
2607
2608 #[test]
2609 fn test_diagnostic_position_multiline() {
2610 let temp_dir = tempdir().unwrap();
2612 let base_path = temp_dir.path();
2613
2614 let content = r#"# Title
2615Some text on line 2
2616[link on line 3](missing1.md)
2617More text
2618[link on line 5](missing2.md)"#;
2619
2620 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2621 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2622 let result = rule.check(&ctx).unwrap();
2623
2624 assert_eq!(result.len(), 2, "Should have two warnings");
2625
2626 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2628 assert!(result[0].message.contains("missing1.md"));
2629
2630 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2632 assert!(result[1].message.contains("missing2.md"));
2633 }
2634
2635 #[test]
2636 fn test_diagnostic_position_with_spaces() {
2637 let temp_dir = tempdir().unwrap();
2639 let base_path = temp_dir.path();
2640
2641 let content = "[link]( missing.md )";
2642 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2647 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2648 let result = rule.check(&ctx).unwrap();
2649
2650 assert_eq!(result.len(), 1, "Should have exactly one warning");
2651 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2653 }
2654
2655 #[test]
2656 fn test_diagnostic_position_image() {
2657 let temp_dir = tempdir().unwrap();
2659 let base_path = temp_dir.path();
2660
2661 let content = "";
2662
2663 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665 let result = rule.check(&ctx).unwrap();
2666
2667 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2668 assert_eq!(result[0].line, 1);
2669 assert!(result[0].column > 0, "Should have valid column position");
2671 assert!(result[0].message.contains("missing.jpg"));
2672 }
2673
2674 #[test]
2675 fn test_diagnostic_position_non_ascii_image() {
2676 let temp_dir = tempdir().unwrap();
2678 let base_path = temp_dir.path();
2679
2680 let content = "你好你好";
2683
2684 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2685 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2686 let result = rule.check(&ctx).unwrap();
2687
2688 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2689 assert_eq!(result[0].line, 1, "Should be on line 1");
2690 assert_eq!(
2691 result[0].column, 5,
2692 "Column must be a character offset, not a byte offset"
2693 );
2694 assert!(result[0].message.contains("not-exist.png"));
2695 }
2696
2697 #[test]
2698 fn test_diagnostic_position_non_ascii_reference_def() {
2699 let temp_dir = tempdir().unwrap();
2703 let base_path = temp_dir.path();
2704
2705 let content = "[你好]: not-exist.md";
2708
2709 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2710 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2711 let result = rule.check(&ctx).unwrap();
2712
2713 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2714 assert_eq!(result[0].line, 1, "Should be on line 1");
2715 assert_eq!(
2716 result[0].column, 7,
2717 "Column must be a character offset, not a byte offset"
2718 );
2719 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2720 }
2721
2722 #[test]
2723 fn test_wikilinks_skipped() {
2724 let temp_dir = tempdir().unwrap();
2727 let base_path = temp_dir.path();
2728
2729 let content = r#"# Test Document
2730
2731[[Microsoft#Windows OS]]
2732[[SomePage]]
2733[[Page With Spaces]]
2734[[path/to/page#section]]
2735[[page|Display Text]]
2736
2737This is a [real missing link](missing.md) that should be flagged.
2738"#;
2739
2740 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2742 let result = rule.check(&ctx).unwrap();
2743
2744 assert_eq!(
2746 result.len(),
2747 1,
2748 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2749 );
2750 assert!(
2751 result[0].message.contains("missing.md"),
2752 "Warning should be for missing.md, not wikilinks"
2753 );
2754 }
2755
2756 #[test]
2757 fn test_wikilinks_not_added_to_index() {
2758 let temp_dir = tempdir().unwrap();
2760 let base_path = temp_dir.path();
2761
2762 let content = r#"# Test Document
2763
2764[[Microsoft#Windows OS]]
2765[[SomePage#section]]
2766[Regular Link](other.md)
2767"#;
2768
2769 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2770 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2771
2772 let mut file_index = FileIndex::new();
2773 rule.contribute_to_index(&ctx, &mut file_index);
2774
2775 let cross_file_links = &file_index.cross_file_links;
2778 assert_eq!(
2779 cross_file_links.len(),
2780 1,
2781 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2782 );
2783 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2784 }
2785
2786 #[test]
2787 fn test_reference_definition_missing_file() {
2788 let temp_dir = tempdir().unwrap();
2790 let base_path = temp_dir.path();
2791
2792 let content = r#"# Test Document
2793
2794[test]: ./missing.md
2795[example]: ./nonexistent.html
2796
2797Use [test] and [example] here.
2798"#;
2799
2800 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2801 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2802 let result = rule.check(&ctx).unwrap();
2803
2804 assert_eq!(
2806 result.len(),
2807 2,
2808 "Should have warnings for missing reference definition targets. Got: {result:?}"
2809 );
2810 assert!(
2811 result.iter().any(|w| w.message.contains("missing.md")),
2812 "Should warn about missing.md"
2813 );
2814 assert!(
2815 result.iter().any(|w| w.message.contains("nonexistent.html")),
2816 "Should warn about nonexistent.html"
2817 );
2818 }
2819
2820 #[test]
2821 fn test_reference_definition_existing_file() {
2822 let temp_dir = tempdir().unwrap();
2824 let base_path = temp_dir.path();
2825
2826 let exists_path = base_path.join("exists.md");
2828 File::create(&exists_path)
2829 .unwrap()
2830 .write_all(b"# Existing file")
2831 .unwrap();
2832
2833 let content = r#"# Test Document
2834
2835[test]: ./exists.md
2836
2837Use [test] here.
2838"#;
2839
2840 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2841 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2842 let result = rule.check(&ctx).unwrap();
2843
2844 assert!(
2846 result.is_empty(),
2847 "Should not warn about existing file. Got: {result:?}"
2848 );
2849 }
2850
2851 #[test]
2852 fn test_reference_definition_external_url_skipped() {
2853 let temp_dir = tempdir().unwrap();
2855 let base_path = temp_dir.path();
2856
2857 let content = r#"# Test Document
2858
2859[google]: https://google.com
2860[example]: http://example.org
2861[mail]: mailto:test@example.com
2862[ftp]: ftp://files.example.com
2863[local]: ./missing.md
2864
2865Use [google], [example], [mail], [ftp], [local] here.
2866"#;
2867
2868 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2869 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2870 let result = rule.check(&ctx).unwrap();
2871
2872 assert_eq!(
2874 result.len(),
2875 1,
2876 "Should only warn about local missing file. Got: {result:?}"
2877 );
2878 assert!(
2879 result[0].message.contains("missing.md"),
2880 "Warning should be for missing.md"
2881 );
2882 }
2883
2884 #[test]
2885 fn test_reference_definition_fragment_only_skipped() {
2886 let temp_dir = tempdir().unwrap();
2888 let base_path = temp_dir.path();
2889
2890 let content = r#"# Test Document
2891
2892[section]: #my-section
2893
2894Use [section] here.
2895"#;
2896
2897 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let result = rule.check(&ctx).unwrap();
2900
2901 assert!(
2903 result.is_empty(),
2904 "Should not warn about fragment-only reference. Got: {result:?}"
2905 );
2906 }
2907
2908 #[test]
2909 fn test_reference_definition_column_position() {
2910 let temp_dir = tempdir().unwrap();
2912 let base_path = temp_dir.path();
2913
2914 let content = "[ref]: ./missing.md";
2917 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2921 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2922 let result = rule.check(&ctx).unwrap();
2923
2924 assert_eq!(result.len(), 1, "Should have exactly one warning");
2925 assert_eq!(result[0].line, 1, "Should be on line 1");
2926 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2927 }
2928
2929 #[test]
2930 fn test_reference_definition_html_with_md_source() {
2931 let temp_dir = tempdir().unwrap();
2933 let base_path = temp_dir.path();
2934
2935 let md_file = base_path.join("guide.md");
2937 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2938
2939 let content = r#"# Test Document
2940
2941[guide]: ./guide.html
2942[missing]: ./missing.html
2943
2944Use [guide] and [missing] here.
2945"#;
2946
2947 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2948 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2949 let result = rule.check(&ctx).unwrap();
2950
2951 assert_eq!(
2953 result.len(),
2954 1,
2955 "Should only warn about missing source. Got: {result:?}"
2956 );
2957 assert!(result[0].message.contains("missing.html"));
2958 }
2959
2960 #[test]
2961 fn test_reference_definition_url_encoded() {
2962 let temp_dir = tempdir().unwrap();
2964 let base_path = temp_dir.path();
2965
2966 let file_with_spaces = base_path.join("file with spaces.md");
2968 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2969
2970 let content = r#"# Test Document
2971
2972[spaces]: ./file%20with%20spaces.md
2973[missing]: ./missing%20file.md
2974
2975Use [spaces] and [missing] here.
2976"#;
2977
2978 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2979 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2980 let result = rule.check(&ctx).unwrap();
2981
2982 assert_eq!(
2984 result.len(),
2985 1,
2986 "Should only warn about missing URL-encoded file. Got: {result:?}"
2987 );
2988 assert!(result[0].message.contains("missing%20file.md"));
2989 }
2990
2991 #[test]
2992 fn test_inline_and_reference_both_checked() {
2993 let temp_dir = tempdir().unwrap();
2995 let base_path = temp_dir.path();
2996
2997 let content = r#"# Test Document
2998
2999[inline link](./inline-missing.md)
3000[ref]: ./ref-missing.md
3001
3002Use [ref] here.
3003"#;
3004
3005 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3006 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3007 let result = rule.check(&ctx).unwrap();
3008
3009 assert_eq!(
3011 result.len(),
3012 2,
3013 "Should warn about both inline and reference links. Got: {result:?}"
3014 );
3015 assert!(
3016 result.iter().any(|w| w.message.contains("inline-missing.md")),
3017 "Should warn about inline-missing.md"
3018 );
3019 assert!(
3020 result.iter().any(|w| w.message.contains("ref-missing.md")),
3021 "Should warn about ref-missing.md"
3022 );
3023 }
3024
3025 #[test]
3026 fn test_footnote_definitions_not_flagged() {
3027 let rule = MD057ExistingRelativeLinks::default();
3030
3031 let content = r#"# Title
3032
3033A footnote[^1].
3034
3035[^1]: [link](https://www.google.com).
3036"#;
3037
3038 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3039 let result = rule.check(&ctx).unwrap();
3040
3041 assert!(
3042 result.is_empty(),
3043 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_footnote_with_relative_link_inside() {
3049 let rule = MD057ExistingRelativeLinks::default();
3052
3053 let content = r#"# Title
3054
3055See the footnote[^1].
3056
3057[^1]: Check out [this file](./existing.md) for more info.
3058[^2]: Also see [missing](./does-not-exist.md).
3059"#;
3060
3061 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3062 let result = rule.check(&ctx).unwrap();
3063
3064 for warning in &result {
3069 assert!(
3070 !warning.message.contains("[this file]"),
3071 "Footnote content should not be treated as URL: {warning:?}"
3072 );
3073 assert!(
3074 !warning.message.contains("[missing]"),
3075 "Footnote content should not be treated as URL: {warning:?}"
3076 );
3077 }
3078 }
3079
3080 #[test]
3081 fn test_mixed_footnotes_and_reference_definitions() {
3082 let temp_dir = tempdir().unwrap();
3084 let base_path = temp_dir.path();
3085
3086 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3087
3088 let content = r#"# Title
3089
3090A footnote[^1] and a [ref link][myref].
3091
3092[^1]: This is a footnote with [link](https://example.com).
3093
3094[myref]: ./missing-file.md "This should be checked"
3095"#;
3096
3097 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3098 let result = rule.check(&ctx).unwrap();
3099
3100 assert_eq!(
3102 result.len(),
3103 1,
3104 "Should only warn about the regular reference definition. Got: {result:?}"
3105 );
3106 assert!(
3107 result[0].message.contains("missing-file.md"),
3108 "Should warn about missing-file.md in reference definition"
3109 );
3110 }
3111
3112 #[test]
3113 fn test_absolute_links_ignore_by_default() {
3114 let temp_dir = tempdir().unwrap();
3116 let base_path = temp_dir.path();
3117
3118 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3119
3120 let content = r#"# Links
3121
3122[API docs](/api/v1/users)
3123[Blog post](/blog/2024/release.html)
3124
3125
3126[ref]: /docs/reference.md
3127"#;
3128
3129 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3130 let result = rule.check(&ctx).unwrap();
3131
3132 assert!(
3134 result.is_empty(),
3135 "Absolute links should be ignored by default. Got: {result:?}"
3136 );
3137 }
3138
3139 #[test]
3140 fn test_absolute_links_warn_config() {
3141 let temp_dir = tempdir().unwrap();
3143 let base_path = temp_dir.path();
3144
3145 let config = MD057Config {
3146 absolute_links: AbsoluteLinksOption::Warn,
3147 ..Default::default()
3148 };
3149 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3150
3151 let content = r#"# Links
3152
3153[API docs](/api/v1/users)
3154[Blog post](/blog/2024/release.html)
3155"#;
3156
3157 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3158 let result = rule.check(&ctx).unwrap();
3159
3160 assert_eq!(
3162 result.len(),
3163 2,
3164 "Should warn about both absolute links. Got: {result:?}"
3165 );
3166 assert!(
3167 result[0].message.contains("cannot be validated locally"),
3168 "Warning should explain why: {}",
3169 result[0].message
3170 );
3171 assert!(
3172 result[0].message.contains("/api/v1/users"),
3173 "Warning should include the link path"
3174 );
3175 }
3176
3177 #[test]
3178 fn test_absolute_links_warn_images() {
3179 let temp_dir = tempdir().unwrap();
3181 let base_path = temp_dir.path();
3182
3183 let config = MD057Config {
3184 absolute_links: AbsoluteLinksOption::Warn,
3185 ..Default::default()
3186 };
3187 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3188
3189 let content = r#"# Images
3190
3191
3192"#;
3193
3194 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3195 let result = rule.check(&ctx).unwrap();
3196
3197 assert_eq!(
3198 result.len(),
3199 1,
3200 "Should warn about absolute image path. Got: {result:?}"
3201 );
3202 assert!(
3203 result[0].message.contains("/assets/logo.png"),
3204 "Warning should include the image path"
3205 );
3206 }
3207
3208 #[test]
3209 fn test_absolute_links_warn_reference_definitions() {
3210 let temp_dir = tempdir().unwrap();
3212 let base_path = temp_dir.path();
3213
3214 let config = MD057Config {
3215 absolute_links: AbsoluteLinksOption::Warn,
3216 ..Default::default()
3217 };
3218 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3219
3220 let content = r#"# Reference
3221
3222See the [docs][ref].
3223
3224[ref]: /docs/reference.md
3225"#;
3226
3227 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3228 let result = rule.check(&ctx).unwrap();
3229
3230 assert_eq!(
3231 result.len(),
3232 1,
3233 "Should warn about absolute reference definition. Got: {result:?}"
3234 );
3235 assert!(
3236 result[0].message.contains("/docs/reference.md"),
3237 "Warning should include the reference path"
3238 );
3239 }
3240
3241 #[test]
3242 fn test_search_paths_inline_link() {
3243 let temp_dir = tempdir().unwrap();
3244 let base_path = temp_dir.path();
3245
3246 let assets_dir = base_path.join("assets");
3248 std::fs::create_dir_all(&assets_dir).unwrap();
3249 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3250
3251 let config = MD057Config {
3252 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3253 ..Default::default()
3254 };
3255 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3256
3257 let content = "# Test\n\n[Photo](photo.png)\n";
3258 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3259 let result = rule.check(&ctx).unwrap();
3260
3261 assert!(
3262 result.is_empty(),
3263 "Should find photo.png via search-paths. Got: {result:?}"
3264 );
3265 }
3266
3267 #[test]
3268 fn test_search_paths_image() {
3269 let temp_dir = tempdir().unwrap();
3270 let base_path = temp_dir.path();
3271
3272 let assets_dir = base_path.join("attachments");
3273 std::fs::create_dir_all(&assets_dir).unwrap();
3274 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3275
3276 let config = MD057Config {
3277 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3278 ..Default::default()
3279 };
3280 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3281
3282 let content = "# Test\n\n\n";
3283 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3284 let result = rule.check(&ctx).unwrap();
3285
3286 assert!(
3287 result.is_empty(),
3288 "Should find diagram.svg via search-paths. Got: {result:?}"
3289 );
3290 }
3291
3292 #[test]
3293 fn test_search_paths_reference_definition() {
3294 let temp_dir = tempdir().unwrap();
3295 let base_path = temp_dir.path();
3296
3297 let assets_dir = base_path.join("images");
3298 std::fs::create_dir_all(&assets_dir).unwrap();
3299 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3300
3301 let config = MD057Config {
3302 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3303 ..Default::default()
3304 };
3305 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3306
3307 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3309 let result = rule.check(&ctx).unwrap();
3310
3311 assert!(
3312 result.is_empty(),
3313 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3314 );
3315 }
3316
3317 #[test]
3318 fn test_search_paths_still_warns_when_truly_missing() {
3319 let temp_dir = tempdir().unwrap();
3320 let base_path = temp_dir.path();
3321
3322 let assets_dir = base_path.join("assets");
3323 std::fs::create_dir_all(&assets_dir).unwrap();
3324
3325 let config = MD057Config {
3326 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3327 ..Default::default()
3328 };
3329 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3330
3331 let content = "# Test\n\n\n";
3332 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3333 let result = rule.check(&ctx).unwrap();
3334
3335 assert_eq!(
3336 result.len(),
3337 1,
3338 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3339 );
3340 }
3341
3342 #[test]
3343 fn test_search_paths_nonexistent_directory() {
3344 let temp_dir = tempdir().unwrap();
3345 let base_path = temp_dir.path();
3346
3347 let config = MD057Config {
3348 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3349 ..Default::default()
3350 };
3351 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3352
3353 let content = "# Test\n\n\n";
3354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3355 let result = rule.check(&ctx).unwrap();
3356
3357 assert_eq!(
3358 result.len(),
3359 1,
3360 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_obsidian_attachment_folder_named() {
3366 let temp_dir = tempdir().unwrap();
3367 let vault = temp_dir.path().join("vault");
3368 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3369 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3370 std::fs::create_dir_all(vault.join("notes")).unwrap();
3371
3372 std::fs::write(
3373 vault.join(".obsidian/app.json"),
3374 r#"{"attachmentFolderPath": "Attachments"}"#,
3375 )
3376 .unwrap();
3377 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3378
3379 let notes_dir = vault.join("notes");
3380 let source_file = notes_dir.join("test.md");
3381 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3382
3383 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3384
3385 let content = "# Test\n\n\n";
3386 let ctx =
3387 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3388 let result = rule.check(&ctx).unwrap();
3389
3390 assert!(
3391 result.is_empty(),
3392 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3393 );
3394 }
3395
3396 #[test]
3397 fn test_obsidian_attachment_same_folder_as_file() {
3398 let temp_dir = tempdir().unwrap();
3399 let vault = temp_dir.path().join("vault-rf");
3400 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3401 std::fs::create_dir_all(vault.join("notes")).unwrap();
3402
3403 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3404
3405 let notes_dir = vault.join("notes");
3407 let source_file = notes_dir.join("test.md");
3408 std::fs::write(&source_file, "placeholder").unwrap();
3409 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3410
3411 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3412
3413 let content = "# Test\n\n\n";
3414 let ctx =
3415 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3416 let result = rule.check(&ctx).unwrap();
3417
3418 assert!(
3419 result.is_empty(),
3420 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3421 );
3422 }
3423
3424 #[test]
3425 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3426 let temp_dir = tempdir().unwrap();
3427 let vault = temp_dir.path().join("vault-nf");
3428 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3429 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3430 std::fs::create_dir_all(vault.join("notes")).unwrap();
3431
3432 std::fs::write(
3433 vault.join(".obsidian/app.json"),
3434 r#"{"attachmentFolderPath": "Attachments"}"#,
3435 )
3436 .unwrap();
3437 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3438
3439 let notes_dir = vault.join("notes");
3440 let source_file = notes_dir.join("test.md");
3441 std::fs::write(&source_file, "placeholder").unwrap();
3442
3443 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3444
3445 let content = "# Test\n\n\n";
3446 let ctx =
3448 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3449 let result = rule.check(&ctx).unwrap();
3450
3451 assert_eq!(
3452 result.len(),
3453 1,
3454 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3455 );
3456 }
3457
3458 #[test]
3459 fn test_search_paths_combined_with_obsidian() {
3460 let temp_dir = tempdir().unwrap();
3461 let vault = temp_dir.path().join("vault-combo");
3462 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3463 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3464 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3465 std::fs::create_dir_all(vault.join("notes")).unwrap();
3466
3467 std::fs::write(
3468 vault.join(".obsidian/app.json"),
3469 r#"{"attachmentFolderPath": "Attachments"}"#,
3470 )
3471 .unwrap();
3472 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3473 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3474
3475 let notes_dir = vault.join("notes");
3476 let source_file = notes_dir.join("test.md");
3477 std::fs::write(&source_file, "placeholder").unwrap();
3478
3479 let extra_assets_dir = vault.join("extra-assets");
3480 let config = MD057Config {
3481 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3482 ..Default::default()
3483 };
3484 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3485
3486 let content = "# Test\n\n\n\n\n";
3488 let ctx =
3489 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3490 let result = rule.check(&ctx).unwrap();
3491
3492 assert!(
3493 result.is_empty(),
3494 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3495 );
3496 }
3497
3498 #[test]
3499 fn test_obsidian_attachment_subfolder_under_file() {
3500 let temp_dir = tempdir().unwrap();
3501 let vault = temp_dir.path().join("vault-sub");
3502 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3503 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3504
3505 std::fs::write(
3506 vault.join(".obsidian/app.json"),
3507 r#"{"attachmentFolderPath": "./assets"}"#,
3508 )
3509 .unwrap();
3510 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3511
3512 let notes_dir = vault.join("notes");
3513 let source_file = notes_dir.join("test.md");
3514 std::fs::write(&source_file, "placeholder").unwrap();
3515
3516 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3517
3518 let content = "# Test\n\n\n";
3519 let ctx =
3520 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3521 let result = rule.check(&ctx).unwrap();
3522
3523 assert!(
3524 result.is_empty(),
3525 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3526 );
3527 }
3528
3529 #[test]
3530 fn test_obsidian_attachment_vault_root() {
3531 let temp_dir = tempdir().unwrap();
3532 let vault = temp_dir.path().join("vault-root");
3533 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3534 std::fs::create_dir_all(vault.join("notes")).unwrap();
3535
3536 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3538 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3539
3540 let notes_dir = vault.join("notes");
3541 let source_file = notes_dir.join("test.md");
3542 std::fs::write(&source_file, "placeholder").unwrap();
3543
3544 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3545
3546 let content = "# Test\n\n\n";
3547 let ctx =
3548 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3549 let result = rule.check(&ctx).unwrap();
3550
3551 assert!(
3552 result.is_empty(),
3553 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3554 );
3555 }
3556
3557 #[test]
3558 fn test_search_paths_multiple_directories() {
3559 let temp_dir = tempdir().unwrap();
3560 let base_path = temp_dir.path();
3561
3562 let dir_a = base_path.join("dir-a");
3563 let dir_b = base_path.join("dir-b");
3564 std::fs::create_dir_all(&dir_a).unwrap();
3565 std::fs::create_dir_all(&dir_b).unwrap();
3566 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3567 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3568
3569 let config = MD057Config {
3570 search_paths: vec![
3571 dir_a.to_string_lossy().into_owned(),
3572 dir_b.to_string_lossy().into_owned(),
3573 ],
3574 ..Default::default()
3575 };
3576 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3577
3578 let content = "# Test\n\n\n\n\n";
3579 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3580 let result = rule.check(&ctx).unwrap();
3581
3582 assert!(
3583 result.is_empty(),
3584 "Should find files across multiple search paths. Got: {result:?}"
3585 );
3586 }
3587
3588 #[test]
3589 fn test_cross_file_check_with_search_paths() {
3590 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3591
3592 let temp_dir = tempdir().unwrap();
3593 let base_path = temp_dir.path();
3594
3595 let docs_dir = base_path.join("docs");
3597 std::fs::create_dir_all(&docs_dir).unwrap();
3598 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3599
3600 let config = MD057Config {
3601 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3602 ..Default::default()
3603 };
3604 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3605
3606 let file_path = base_path.join("README.md");
3607 std::fs::write(&file_path, "# Readme\n").unwrap();
3608
3609 let mut file_index = FileIndex::default();
3610 file_index.cross_file_links.push(CrossFileLinkIndex {
3611 target_path: "guide.md".to_string(),
3612 fragment: String::new(),
3613 line: 3,
3614 column: 1,
3615 });
3616
3617 let workspace_index = WorkspaceIndex::new();
3618
3619 let result = rule
3620 .cross_file_check(&file_path, &file_index, &workspace_index)
3621 .unwrap();
3622
3623 assert!(
3624 result.is_empty(),
3625 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3626 );
3627 }
3628
3629 #[test]
3630 fn test_cross_file_check_with_obsidian_flavor() {
3631 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3632
3633 let temp_dir = tempdir().unwrap();
3634 let vault = temp_dir.path().join("vault-xf");
3635 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3636 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3637 std::fs::create_dir_all(vault.join("notes")).unwrap();
3638
3639 std::fs::write(
3640 vault.join(".obsidian/app.json"),
3641 r#"{"attachmentFolderPath": "Attachments"}"#,
3642 )
3643 .unwrap();
3644 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3645
3646 let notes_dir = vault.join("notes");
3647 let file_path = notes_dir.join("test.md");
3648 std::fs::write(&file_path, "placeholder").unwrap();
3649
3650 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3651 .with_path(¬es_dir)
3652 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3653
3654 let mut file_index = FileIndex::default();
3655 file_index.cross_file_links.push(CrossFileLinkIndex {
3656 target_path: "ref.md".to_string(),
3657 fragment: String::new(),
3658 line: 3,
3659 column: 1,
3660 });
3661
3662 let workspace_index = WorkspaceIndex::new();
3663
3664 let result = rule
3665 .cross_file_check(&file_path, &file_index, &workspace_index)
3666 .unwrap();
3667
3668 assert!(
3669 result.is_empty(),
3670 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3671 );
3672 }
3673
3674 #[test]
3675 fn test_check_clears_stale_cache() {
3676 let temp_dir = tempdir().unwrap();
3679 let base_path = temp_dir.path();
3680
3681 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3682
3683 let phantom_path = base_path.join("phantom.md");
3685 {
3686 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3687 cache.insert(phantom_path.clone(), true);
3688 }
3689
3690 let content = "[phantom](phantom.md)\n";
3691 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3692 let warnings = rule.check(&ctx).unwrap();
3693
3694 assert_eq!(
3696 warnings.len(),
3697 1,
3698 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3699 );
3700 assert!(warnings[0].message.contains("phantom.md"));
3701 }
3702
3703 #[test]
3704 fn test_check_does_not_carry_over_cache_between_runs() {
3705 let temp_dir = tempdir().unwrap();
3707 let base_path = temp_dir.path();
3708
3709 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3710
3711 let content = "[missing](nonexistent.md)\n";
3712 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3713
3714 let warnings_1 = rule.check(&ctx).unwrap();
3716 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3717
3718 let nonexistent_path = base_path.join("nonexistent.md");
3720 {
3721 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3722 cache.insert(nonexistent_path.clone(), true);
3723 }
3724
3725 let warnings_2 = rule.check(&ctx).unwrap();
3727 assert_eq!(
3728 warnings_2.len(),
3729 1,
3730 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3731 );
3732 }
3733
3734 #[test]
3740 fn test_no_duplicate_warnings_for_broken_relative_link() {
3741 use crate::workspace_index::WorkspaceIndex;
3742
3743 let temp_dir = tempdir().unwrap();
3744 let base_path = temp_dir.path();
3745
3746 let source_file = base_path.join("index.md");
3748 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3749
3750 let content = "[broken](does/not/exist.md)\n";
3751
3752 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3753
3754 let ctx = crate::lint_context::LintContext::new(
3756 content,
3757 crate::config::MarkdownFlavor::Standard,
3758 Some(source_file.clone()),
3759 );
3760 let check_warnings = rule.check(&ctx).unwrap();
3761
3762 let mut file_index = FileIndex::new();
3764 rule.contribute_to_index(&ctx, &mut file_index);
3765 let workspace_index = WorkspaceIndex::new();
3766 let cross_warnings = rule
3767 .cross_file_check(&source_file, &file_index, &workspace_index)
3768 .unwrap();
3769
3770 let total = check_warnings.len() + cross_warnings.len();
3771 assert_eq!(
3772 total, 1,
3773 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3774 check={check_warnings:?}, cross={cross_warnings:?}"
3775 );
3776 }
3777
3778 #[test]
3783 fn test_absolute_dir_link_accepted_relative_to_roots() {
3784 let temp_dir = tempdir().unwrap();
3785 let root = temp_dir.path();
3786
3787 let dir_d = root.join("d");
3789 std::fs::create_dir_all(&dir_d).unwrap();
3790 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3791
3792 let content = "\
3795[absolute dir](/d)\n\
3796[relative dir](d)\n\
3797[absolute file](/d/foo.md)\n\
3798[relative file](d/foo.md)\n";
3799
3800 let config = MD057Config {
3801 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3802 roots: vec![],
3803 ..Default::default()
3804 };
3805 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3806
3807 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3808 let result = rule.check(&ctx).unwrap();
3809
3810 assert!(
3811 result.is_empty(),
3812 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3813 );
3814 }
3815
3816 #[test]
3819 fn test_absolute_trailing_slash_dir_link_requires_index() {
3820 let temp_dir = tempdir().unwrap();
3821 let root = temp_dir.path();
3822
3823 let dir_d = root.join("d");
3825 std::fs::create_dir_all(&dir_d).unwrap();
3826 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3827
3828 let content = "[dir with slash](/d/)\n";
3830
3831 let config = MD057Config {
3832 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3833 roots: vec![],
3834 ..Default::default()
3835 };
3836 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3837
3838 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3839 let result = rule.check(&ctx).unwrap();
3840
3841 assert_eq!(
3842 result.len(),
3843 1,
3844 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3845 );
3846 }
3847
3848 #[test]
3852 fn test_docs_dir_variant_still_enforces_index_md() {
3853 let temp_dir = tempdir().unwrap();
3854 let root = temp_dir.path();
3855
3856 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3858
3859 let docs_dir = root.join("docs");
3861 std::fs::create_dir_all(&docs_dir).unwrap();
3862 let section_dir = docs_dir.join("section");
3863 std::fs::create_dir_all(§ion_dir).unwrap();
3864 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3865
3866 let source_file = docs_dir.join("index.md");
3868 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3869
3870 let config = MD057Config {
3871 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3872 ..Default::default()
3873 };
3874 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3875
3876 let content = "[sec](/section)\n";
3877 let ctx = crate::lint_context::LintContext::new(
3878 content,
3879 crate::config::MarkdownFlavor::Standard,
3880 Some(source_file.clone()),
3881 );
3882 let result = rule.check(&ctx).unwrap();
3883
3884 assert_eq!(
3886 result.len(),
3887 1,
3888 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3889 );
3890 assert!(
3891 result[0].message.contains("index.md") || result[0].message.contains("section"),
3892 "Message should mention the directory or missing index.md: {}",
3893 result[0].message
3894 );
3895 }
3896
3897 #[test]
3903 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3904 let temp_dir = tempdir().unwrap();
3905 let root = temp_dir.path();
3906
3907 let guide_dir = root.join("guide");
3909 std::fs::create_dir_all(&guide_dir).unwrap();
3910 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3911
3912 let content = "[guide with fragment](/guide/#intro)\n";
3914
3915 let config = MD057Config {
3916 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3917 roots: vec![],
3918 ..Default::default()
3919 };
3920 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3921 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3922 let result = rule.check(&ctx).unwrap();
3923
3924 assert_eq!(
3925 result.len(),
3926 1,
3927 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3928 );
3929 }
3930}