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
556impl Rule for MD057ExistingRelativeLinks {
557 fn name(&self) -> &'static str {
558 "MD057"
559 }
560
561 fn description(&self) -> &'static str {
562 "Relative links should point to existing files"
563 }
564
565 fn category(&self) -> RuleCategory {
566 RuleCategory::Link
567 }
568
569 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
570 ctx.content.is_empty() || !ctx.likely_has_links_or_images()
571 }
572
573 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
574 let content = ctx.content;
575
576 if content.is_empty() || !content.contains('[') {
578 return Ok(Vec::new());
579 }
580
581 if !content.contains("](") && !content.contains("]:") {
584 return Ok(Vec::new());
585 }
586
587 reset_file_existence_cache();
589
590 let mut warnings = Vec::new();
591
592 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
596
597 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| PROJECT_ROOT.clone());
601
602 let base_path: Option<PathBuf> = {
606 if explicit_base.is_some() {
607 explicit_base
608 } else if let Some(ref source_file) = ctx.source_file {
609 let resolved_file = source_file.canonicalize().unwrap_or_else(|_| source_file.clone());
613 resolved_file
614 .parent()
615 .map(std::path::Path::to_path_buf)
616 .or_else(|| Some(CURRENT_DIR.clone()))
617 } else {
618 None
620 }
621 };
622
623 let Some(base_path) = base_path else {
625 return Ok(warnings);
626 };
627
628 let extra_search_paths =
630 self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path, &project_root);
631
632 if !ctx.links.is_empty() {
634 let line_index = &ctx.line_index;
636
637 let lines = ctx.raw_lines();
639
640 let mut processed_lines = std::collections::HashSet::new();
643
644 for link in &ctx.links {
645 let line_idx = link.line - 1;
646 if line_idx >= lines.len() {
647 continue;
648 }
649
650 if ctx.line_info(link.line).is_some_and(|info| info.in_pymdown_block) {
652 continue;
653 }
654
655 if !processed_lines.insert(line_idx) {
657 continue;
658 }
659
660 let line = lines[line_idx];
661
662 if !line.contains("](") {
664 continue;
665 }
666
667 for link_match in LINK_START_REGEX.find_iter(line) {
669 let start_pos = link_match.start();
670 let end_pos = link_match.end();
671
672 let line_start_byte = line_index.get_line_start_byte(line_idx + 1).unwrap_or(0);
674 let absolute_start_pos = line_start_byte + start_pos;
675
676 if ctx.is_in_code_span_byte(absolute_start_pos) {
678 continue;
679 }
680
681 if ctx.is_in_math_span(absolute_start_pos) {
683 continue;
684 }
685
686 let caps_and_url = URL_EXTRACT_ANGLE_BRACKET_REGEX
690 .captures_at(line, end_pos - 1)
691 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
692 .or_else(|| {
693 URL_EXTRACT_REGEX
694 .captures_at(line, end_pos - 1)
695 .and_then(|caps| caps.get(1).map(|g| (caps, g)))
696 });
697
698 if let Some((caps, url_group)) = caps_and_url {
699 let url = url_group.as_str().trim();
700
701 if url.is_empty() {
703 continue;
704 }
705
706 if url.starts_with('`') && url.ends_with('`') {
710 continue;
711 }
712
713 if self.is_external_url(url) || self.is_fragment_only_link(url) {
715 continue;
716 }
717
718 if Self::is_absolute_path(url) {
720 match self.config.absolute_links {
721 AbsoluteLinksOption::Warn => {
722 let url_start = url_group.start();
723 let url_end = url_group.end();
724 warnings.push(LintWarning {
725 rule_name: Some(self.name().to_string()),
726 line: link.line,
727 column: byte_to_char_count(line, url_start),
728 end_line: link.line,
729 end_column: byte_to_char_count(line, url_end),
730 message: format!("Absolute link '{url}' cannot be validated locally"),
731 severity: Severity::Warning,
732 fix: None,
733 });
734 }
735 AbsoluteLinksOption::RelativeToDocs => {
736 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
737 let url_start = url_group.start();
738 let url_end = url_group.end();
739 warnings.push(LintWarning {
740 rule_name: Some(self.name().to_string()),
741 line: link.line,
742 column: byte_to_char_count(line, url_start),
743 end_line: link.line,
744 end_column: byte_to_char_count(line, url_end),
745 message: msg,
746 severity: Severity::Warning,
747 fix: None,
748 });
749 }
750 }
751 AbsoluteLinksOption::RelativeToRoots => {
752 if let Some(msg) =
753 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
754 {
755 let url_start = url_group.start();
756 let url_end = url_group.end();
757 warnings.push(LintWarning {
758 rule_name: Some(self.name().to_string()),
759 line: link.line,
760 column: byte_to_char_count(line, url_start),
761 end_line: link.line,
762 end_column: byte_to_char_count(line, url_end),
763 message: msg,
764 severity: Severity::Warning,
765 fix: None,
766 });
767 }
768 }
769 AbsoluteLinksOption::Ignore => {}
770 }
771 continue;
772 }
773
774 let full_url_for_compact = if let Some(frag) = caps.get(2) {
778 format!("{url}{}", frag.as_str())
779 } else {
780 url.to_string()
781 };
782 if let Some(suggestion) = self.compact_path_suggestion(&full_url_for_compact, &base_path) {
783 let url_start = url_group.start();
784 let url_end = caps.get(2).map_or(url_group.end(), |frag| frag.end());
785 let fix_byte_start = line_start_byte + url_start;
786 let fix_byte_end = line_start_byte + url_end;
787 warnings.push(LintWarning {
788 rule_name: Some(self.name().to_string()),
789 line: link.line,
790 column: byte_to_char_count(line, url_start),
791 end_line: link.line,
792 end_column: byte_to_char_count(line, url_end),
793 message: format!(
794 "Relative link '{full_url_for_compact}' can be simplified to '{suggestion}'"
795 ),
796 severity: Severity::Warning,
797 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
798 });
799 }
800
801 let file_path = Self::strip_query_and_fragment(url);
803
804 let decoded_path = Self::url_decode(file_path);
806
807 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
809
810 if file_exists_or_markdown_extension(&resolved_path) {
812 continue; }
814
815 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
817 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
818 && let (Some(stem), Some(parent)) = (
819 resolved_path.file_stem().and_then(|s| s.to_str()),
820 resolved_path.parent(),
821 ) {
822 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
823 let source_path = parent.join(format!("{stem}{md_ext}"));
824 file_exists_with_cache(&source_path)
825 })
826 } else {
827 false
828 };
829
830 if has_md_source {
831 continue; }
833
834 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
836 continue;
837 }
838
839 let url_start = url_group.start();
843 let url_end = url_group.end();
844
845 warnings.push(LintWarning {
846 rule_name: Some(self.name().to_string()),
847 line: link.line,
848 column: byte_to_char_count(line, url_start),
849 end_line: link.line,
850 end_column: byte_to_char_count(line, url_end),
851 message: format!("Relative link '{url}' does not exist"),
852 severity: Severity::Error,
853 fix: None,
854 });
855 }
856 }
857 }
858 }
859
860 for image in &ctx.images {
862 if ctx.line_info(image.line).is_some_and(|info| info.in_pymdown_block) {
864 continue;
865 }
866
867 let url = image.url.as_ref();
868
869 if url.is_empty() {
871 continue;
872 }
873
874 if self.is_external_url(url) || self.is_fragment_only_link(url) {
876 continue;
877 }
878
879 if Self::is_absolute_path(url) {
881 match self.config.absolute_links {
882 AbsoluteLinksOption::Warn => {
883 warnings.push(LintWarning {
884 rule_name: Some(self.name().to_string()),
885 line: image.line,
886 column: image.start_col + 1,
887 end_line: image.line,
888 end_column: image.start_col + 1 + url.chars().count(),
889 message: format!("Absolute link '{url}' cannot be validated locally"),
890 severity: Severity::Warning,
891 fix: None,
892 });
893 }
894 AbsoluteLinksOption::RelativeToDocs => {
895 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
896 warnings.push(LintWarning {
897 rule_name: Some(self.name().to_string()),
898 line: image.line,
899 column: image.start_col + 1,
900 end_line: image.line,
901 end_column: image.start_col + 1 + url.chars().count(),
902 message: msg,
903 severity: Severity::Warning,
904 fix: None,
905 });
906 }
907 }
908 AbsoluteLinksOption::RelativeToRoots => {
909 if let Some(msg) =
910 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
911 {
912 warnings.push(LintWarning {
913 rule_name: Some(self.name().to_string()),
914 line: image.line,
915 column: image.start_col + 1,
916 end_line: image.line,
917 end_column: image.start_col + 1 + url.chars().count(),
918 message: msg,
919 severity: Severity::Warning,
920 fix: None,
921 });
922 }
923 }
924 AbsoluteLinksOption::Ignore => {}
925 }
926 continue;
927 }
928
929 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
931 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
934 let fix_byte_start = image.byte_offset + url_offset;
935 let fix_byte_end = fix_byte_start + url.len();
936 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
937 });
938
939 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
940 let img_line_start_byte = ctx.line_index.get_line_start_byte(image.line).unwrap_or(0);
941 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
944 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
945 });
946 warnings.push(LintWarning {
947 rule_name: Some(self.name().to_string()),
948 line: image.line,
949 column: url_col,
950 end_line: image.line,
951 end_column: url_col + url.chars().count(),
952 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
953 severity: Severity::Warning,
954 fix,
955 });
956 }
957
958 let file_path = Self::strip_query_and_fragment(url);
960
961 let decoded_path = Self::url_decode(file_path);
963
964 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
966
967 if file_exists_or_markdown_extension(&resolved_path) {
969 continue; }
971
972 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
974 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
975 && let (Some(stem), Some(parent)) = (
976 resolved_path.file_stem().and_then(|s| s.to_str()),
977 resolved_path.parent(),
978 ) {
979 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
980 let source_path = parent.join(format!("{stem}{md_ext}"));
981 file_exists_with_cache(&source_path)
982 })
983 } else {
984 false
985 };
986
987 if has_md_source {
988 continue; }
990
991 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
993 continue;
994 }
995
996 warnings.push(LintWarning {
999 rule_name: Some(self.name().to_string()),
1000 line: image.line,
1001 column: image.start_col + 1,
1002 end_line: image.line,
1003 end_column: image.start_col + 1 + url.chars().count(),
1004 message: format!("Relative link '{url}' does not exist"),
1005 severity: Severity::Error,
1006 fix: None,
1007 });
1008 }
1009
1010 for ref_def in &ctx.reference_defs {
1012 let url = &ref_def.url;
1013
1014 if url.is_empty() {
1016 continue;
1017 }
1018
1019 if self.is_external_url(url) || self.is_fragment_only_link(url) {
1021 continue;
1022 }
1023
1024 if Self::is_absolute_path(url) {
1026 match self.config.absolute_links {
1027 AbsoluteLinksOption::Warn => {
1028 let line_idx = ref_def.line - 1;
1029 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1030 line_content
1031 .find(url.as_str())
1032 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1033 });
1034 warnings.push(LintWarning {
1035 rule_name: Some(self.name().to_string()),
1036 line: ref_def.line,
1037 column,
1038 end_line: ref_def.line,
1039 end_column: column + url.chars().count(),
1040 message: format!("Absolute link '{url}' cannot be validated locally"),
1041 severity: Severity::Warning,
1042 fix: None,
1043 });
1044 }
1045 AbsoluteLinksOption::RelativeToDocs => {
1046 if let Some(msg) = Self::validate_absolute_link_via_docs_dir(url, &base_path) {
1047 let line_idx = ref_def.line - 1;
1048 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1049 line_content
1050 .find(url.as_str())
1051 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1052 });
1053 warnings.push(LintWarning {
1054 rule_name: Some(self.name().to_string()),
1055 line: ref_def.line,
1056 column,
1057 end_line: ref_def.line,
1058 end_column: column + url.chars().count(),
1059 message: msg,
1060 severity: Severity::Warning,
1061 fix: None,
1062 });
1063 }
1064 }
1065 AbsoluteLinksOption::RelativeToRoots => {
1066 if let Some(msg) =
1067 Self::validate_absolute_link_via_roots(url, &self.config.roots, &project_root)
1068 {
1069 let line_idx = ref_def.line - 1;
1070 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1071 line_content
1072 .find(url.as_str())
1073 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1074 });
1075 warnings.push(LintWarning {
1076 rule_name: Some(self.name().to_string()),
1077 line: ref_def.line,
1078 column,
1079 end_line: ref_def.line,
1080 end_column: column + url.chars().count(),
1081 message: msg,
1082 severity: Severity::Warning,
1083 fix: None,
1084 });
1085 }
1086 }
1087 AbsoluteLinksOption::Ignore => {}
1088 }
1089 continue;
1090 }
1091
1092 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1094 let ref_line_idx = ref_def.line - 1;
1095 let line_content = ctx.raw_lines().get(ref_line_idx).copied().unwrap_or("");
1096 let url_byte = line_content.find(url.as_str());
1099 let col = url_byte.map_or(1, |b| byte_to_char_count(line_content, b));
1100 let ref_line_start_byte = ctx.line_index.get_line_start_byte(ref_def.line).unwrap_or(0);
1101 let fix_byte_start = ref_line_start_byte + url_byte.unwrap_or(0);
1102 let fix_byte_end = fix_byte_start + url.len();
1103 warnings.push(LintWarning {
1104 rule_name: Some(self.name().to_string()),
1105 line: ref_def.line,
1106 column: col,
1107 end_line: ref_def.line,
1108 end_column: col + url.chars().count(),
1109 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1110 severity: Severity::Warning,
1111 fix: Some(Fix::new(fix_byte_start..fix_byte_end, suggestion)),
1112 });
1113 }
1114
1115 let file_path = Self::strip_query_and_fragment(url);
1117
1118 let decoded_path = Self::url_decode(file_path);
1120
1121 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, &base_path);
1123
1124 if file_exists_or_markdown_extension(&resolved_path) {
1126 continue; }
1128
1129 let has_md_source = if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
1131 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1132 && let (Some(stem), Some(parent)) = (
1133 resolved_path.file_stem().and_then(|s| s.to_str()),
1134 resolved_path.parent(),
1135 ) {
1136 MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1137 let source_path = parent.join(format!("{stem}{md_ext}"));
1138 file_exists_with_cache(&source_path)
1139 })
1140 } else {
1141 false
1142 };
1143
1144 if has_md_source {
1145 continue; }
1147
1148 if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) {
1150 continue;
1151 }
1152
1153 let line_idx = ref_def.line - 1;
1156 let column = ctx.raw_lines().get(line_idx).copied().map_or(1, |line_content| {
1157 line_content
1159 .find(url.as_str())
1160 .map_or(1, |url_pos| byte_to_char_count(line_content, url_pos))
1161 });
1162
1163 warnings.push(LintWarning {
1164 rule_name: Some(self.name().to_string()),
1165 line: ref_def.line,
1166 column,
1167 end_line: ref_def.line,
1168 end_column: column + url.chars().count(),
1169 message: format!("Relative link '{url}' does not exist"),
1170 severity: Severity::Error,
1171 fix: None,
1172 });
1173 }
1174
1175 Ok(warnings)
1176 }
1177
1178 fn fix_capability(&self) -> FixCapability {
1179 if self.config.compact_paths {
1180 FixCapability::ConditionallyFixable
1181 } else {
1182 FixCapability::Unfixable
1183 }
1184 }
1185
1186 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1187 if !self.config.compact_paths {
1188 return Ok(ctx.content.to_string());
1189 }
1190
1191 let warnings = self.check(ctx)?;
1192 let warnings =
1193 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1194 let mut content = ctx.content.to_string();
1195
1196 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1198 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1199
1200 for fix in fixes {
1201 if fix.range.end <= content.len() {
1202 content.replace_range(fix.range.clone(), &fix.replacement);
1203 }
1204 }
1205
1206 Ok(content)
1207 }
1208
1209 fn as_any(&self) -> &dyn std::any::Any {
1210 self
1211 }
1212
1213 fn default_config_section(&self) -> Option<(String, toml::Value)> {
1214 let default_config = MD057Config::default();
1215 let json_value = serde_json::to_value(&default_config).ok()?;
1216 let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
1217
1218 if let toml::Value::Table(table) = toml_value {
1219 if !table.is_empty() {
1220 Some((MD057Config::RULE_NAME.to_string(), toml::Value::Table(table)))
1221 } else {
1222 None
1223 }
1224 } else {
1225 None
1226 }
1227 }
1228
1229 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1230 where
1231 Self: Sized,
1232 {
1233 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1234 let mut rule = Self::from_config_struct(rule_config);
1235 rule.flavor = config.global.flavor;
1236 Box::new(rule)
1237 }
1238
1239 fn cross_file_scope(&self) -> CrossFileScope {
1240 CrossFileScope::Workspace
1241 }
1242
1243 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1244 let links = extract_cross_file_links(ctx);
1247 for link in links.relative {
1248 index.add_cross_file_link(link);
1249 }
1250 for link in links.root_relative {
1253 index.add_root_relative_link(link);
1254 }
1255 }
1256
1257 fn cross_file_check(
1258 &self,
1259 _file_path: &Path,
1260 _file_index: &FileIndex,
1261 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1262 ) -> LintResult {
1263 Ok(Vec::new())
1273 }
1274}
1275
1276fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1281 let from_components: Vec<_> = from_dir.components().collect();
1282 let to_components: Vec<_> = to_path.components().collect();
1283
1284 let common_len = from_components
1286 .iter()
1287 .zip(to_components.iter())
1288 .take_while(|(a, b)| a == b)
1289 .count();
1290
1291 let mut result = PathBuf::new();
1292
1293 for _ in common_len..from_components.len() {
1295 result.push("..");
1296 }
1297
1298 for component in &to_components[common_len..] {
1300 result.push(component);
1301 }
1302
1303 result
1304}
1305
1306fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1312 let link_path = Path::new(raw_link_path);
1313
1314 let has_traversal = link_path
1316 .components()
1317 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1318
1319 if !has_traversal {
1320 return None;
1321 }
1322
1323 let combined = source_dir.join(link_path);
1325 let normalized_target = normalize_path(&combined);
1326
1327 let normalized_source = normalize_path(source_dir);
1329 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
1330
1331 if shortest != link_path {
1333 let compact = shortest.to_string_lossy().to_string();
1334 if compact.is_empty() {
1336 return None;
1337 }
1338 Some(compact.replace('\\', "/"))
1340 } else {
1341 None
1342 }
1343}
1344
1345fn normalize_path(path: &Path) -> PathBuf {
1347 let mut components = Vec::new();
1348
1349 for component in path.components() {
1350 match component {
1351 std::path::Component::ParentDir => {
1352 if !components.is_empty() {
1354 components.pop();
1355 }
1356 }
1357 std::path::Component::CurDir => {
1358 }
1360 _ => {
1361 components.push(component);
1362 }
1363 }
1364 }
1365
1366 components.iter().collect()
1367}
1368
1369#[cfg(test)]
1370mod tests {
1371 use super::*;
1372 use crate::workspace_index::CrossFileLinkIndex;
1373 use std::fs::File;
1374 use std::io::Write;
1375 use tempfile::tempdir;
1376
1377 #[test]
1378 fn test_strip_query_and_fragment() {
1379 assert_eq!(
1381 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
1382 "file.png"
1383 );
1384 assert_eq!(
1385 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
1386 "file.png"
1387 );
1388 assert_eq!(
1389 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
1390 "file.png"
1391 );
1392
1393 assert_eq!(
1395 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
1396 "file.md"
1397 );
1398 assert_eq!(
1399 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
1400 "file.md"
1401 );
1402
1403 assert_eq!(
1405 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
1406 "file.md"
1407 );
1408
1409 assert_eq!(
1411 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
1412 "file.png"
1413 );
1414
1415 assert_eq!(
1417 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
1418 "path/to/image.png"
1419 );
1420 assert_eq!(
1421 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
1422 "path/to/image.png"
1423 );
1424
1425 assert_eq!(
1427 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
1428 "file.md"
1429 );
1430 }
1431
1432 #[test]
1433 fn test_url_decode() {
1434 assert_eq!(
1436 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
1437 "penguin with space.jpg"
1438 );
1439
1440 assert_eq!(
1442 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
1443 "assets/my file name.png"
1444 );
1445
1446 assert_eq!(
1448 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
1449 "hello world!.md"
1450 );
1451
1452 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
1454
1455 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
1457
1458 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
1460
1461 assert_eq!(
1463 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
1464 "normal-file.md"
1465 );
1466
1467 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
1469
1470 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
1472
1473 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
1475
1476 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
1478
1479 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
1481
1482 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
1484
1485 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
1487
1488 assert_eq!(
1490 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
1491 "path/to/file.md"
1492 );
1493
1494 assert_eq!(
1496 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
1497 "hello world/foo bar.md"
1498 );
1499
1500 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
1502
1503 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
1505 }
1506
1507 #[test]
1508 fn test_url_encoded_filenames() {
1509 let temp_dir = tempdir().unwrap();
1511 let base_path = temp_dir.path();
1512
1513 let file_with_spaces = base_path.join("penguin with space.jpg");
1515 File::create(&file_with_spaces)
1516 .unwrap()
1517 .write_all(b"image data")
1518 .unwrap();
1519
1520 let subdir = base_path.join("my images");
1522 std::fs::create_dir(&subdir).unwrap();
1523 let nested_file = subdir.join("photo 1.png");
1524 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
1525
1526 let content = r#"
1528# Test Document with URL-Encoded Links
1529
1530
1531
1532
1533"#;
1534
1535 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1536
1537 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1538 let result = rule.check(&ctx).unwrap();
1539
1540 assert_eq!(
1542 result.len(),
1543 1,
1544 "Should only warn about missing%20file.jpg. Got: {result:?}"
1545 );
1546 assert!(
1547 result[0].message.contains("missing%20file.jpg"),
1548 "Warning should mention the URL-encoded filename"
1549 );
1550 }
1551
1552 #[test]
1553 fn test_external_urls() {
1554 let rule = MD057ExistingRelativeLinks::new();
1555
1556 assert!(rule.is_external_url("https://example.com"));
1558 assert!(rule.is_external_url("http://example.com"));
1559 assert!(rule.is_external_url("ftp://example.com"));
1560 assert!(rule.is_external_url("www.example.com"));
1561 assert!(rule.is_external_url("example.com"));
1562
1563 assert!(rule.is_external_url("file:///path/to/file"));
1565 assert!(rule.is_external_url("smb://server/share"));
1566 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
1567 assert!(rule.is_external_url("mailto:user@example.com"));
1568 assert!(rule.is_external_url("tel:+1234567890"));
1569 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
1570 assert!(rule.is_external_url("javascript:void(0)"));
1571 assert!(rule.is_external_url("ssh://git@github.com/repo"));
1572 assert!(rule.is_external_url("git://github.com/repo.git"));
1573
1574 assert!(rule.is_external_url("user@example.com"));
1577 assert!(rule.is_external_url("steering@kubernetes.io"));
1578 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
1579 assert!(rule.is_external_url("user_name@sub.domain.com"));
1580 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
1581
1582 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"));
1593 assert!(!rule.is_external_url("/blog/2024/release.html"));
1594 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
1595 assert!(!rule.is_external_url("/pkg/runtime"));
1596 assert!(!rule.is_external_url("/doc/go1compat"));
1597 assert!(!rule.is_external_url("/index.html"));
1598 assert!(!rule.is_external_url("/assets/logo.png"));
1599
1600 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
1602 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
1603 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
1604 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
1605 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
1606
1607 assert!(rule.is_external_url("~/assets/image.png"));
1610 assert!(rule.is_external_url("~/components/Button.vue"));
1611 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
1615 assert!(rule.is_external_url("@images/photo.jpg"));
1616 assert!(rule.is_external_url("@assets/styles.css"));
1617
1618 assert!(!rule.is_external_url("./relative/path.md"));
1620 assert!(!rule.is_external_url("relative/path.md"));
1621 assert!(!rule.is_external_url("../parent/path.md"));
1622 }
1623
1624 #[test]
1625 fn test_dot_com_only_skips_bare_domains() {
1626 let rule = MD057ExistingRelativeLinks::new();
1627
1628 assert!(rule.is_external_url("example.com"));
1630 assert!(rule.is_external_url("sub.example.com"));
1631
1632 assert!(!rule.is_external_url("../../vendor.com"));
1636 assert!(!rule.is_external_url("./vendor.com"));
1637 assert!(!rule.is_external_url("docs/vendor.com"));
1638 }
1639
1640 #[test]
1641 fn test_framework_path_aliases() {
1642 let temp_dir = tempdir().unwrap();
1644 let base_path = temp_dir.path();
1645
1646 let content = r#"
1648# Framework Path Aliases
1649
1650
1651
1652
1653
1654[Link](@/pages/about.md)
1655
1656This is a [real missing link](missing.md) that should be flagged.
1657"#;
1658
1659 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1660
1661 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1662 let result = rule.check(&ctx).unwrap();
1663
1664 assert_eq!(
1666 result.len(),
1667 1,
1668 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
1669 );
1670 assert!(
1671 result[0].message.contains("missing.md"),
1672 "Warning should be for missing.md"
1673 );
1674 }
1675
1676 #[test]
1677 fn test_url_decode_security_path_traversal() {
1678 let temp_dir = tempdir().unwrap();
1681 let base_path = temp_dir.path();
1682
1683 let file_in_base = base_path.join("safe.md");
1685 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
1686
1687 let content = r#"
1692[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
1693[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
1694[Safe link](safe.md)
1695"#;
1696
1697 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1698
1699 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1700 let result = rule.check(&ctx).unwrap();
1701
1702 assert_eq!(
1705 result.len(),
1706 2,
1707 "Should have warnings for traversal attempts. Got: {result:?}"
1708 );
1709 }
1710
1711 #[test]
1712 fn test_url_encoded_utf8_filenames() {
1713 let temp_dir = tempdir().unwrap();
1715 let base_path = temp_dir.path();
1716
1717 let cafe_file = base_path.join("café.md");
1719 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
1720
1721 let content = r#"
1722[Café link](caf%C3%A9.md)
1723[Missing unicode](r%C3%A9sum%C3%A9.md)
1724"#;
1725
1726 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1727
1728 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1729 let result = rule.check(&ctx).unwrap();
1730
1731 assert_eq!(
1733 result.len(),
1734 1,
1735 "Should only warn about missing résumé.md. Got: {result:?}"
1736 );
1737 assert!(
1738 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
1739 "Warning should mention the URL-encoded filename"
1740 );
1741 }
1742
1743 #[test]
1744 fn test_url_encoded_emoji_filenames() {
1745 let temp_dir = tempdir().unwrap();
1748 let base_path = temp_dir.path();
1749
1750 let emoji_dir = base_path.join("👤 Personal");
1752 std::fs::create_dir(&emoji_dir).unwrap();
1753
1754 let file_path = emoji_dir.join("TV Shows.md");
1756 File::create(&file_path)
1757 .unwrap()
1758 .write_all(b"# TV Shows\n\nContent here.")
1759 .unwrap();
1760
1761 let content = r#"
1764# Test Document
1765
1766[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
1767[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
1768"#;
1769
1770 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1771
1772 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1773 let result = rule.check(&ctx).unwrap();
1774
1775 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
1777 assert!(
1778 result[0].message.contains("Missing.md"),
1779 "Warning should be for Missing.md, got: {}",
1780 result[0].message
1781 );
1782 }
1783
1784 #[test]
1785 fn test_no_warnings_without_base_path() {
1786 let rule = MD057ExistingRelativeLinks::new();
1787 let content = "[Link](missing.md)";
1788
1789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1790 let result = rule.check(&ctx).unwrap();
1791 assert!(result.is_empty(), "Should have no warnings without base path");
1792 }
1793
1794 #[test]
1795 fn test_existing_and_missing_links() {
1796 let temp_dir = tempdir().unwrap();
1798 let base_path = temp_dir.path();
1799
1800 let exists_path = base_path.join("exists.md");
1802 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1803
1804 assert!(exists_path.exists(), "exists.md should exist for this test");
1806
1807 let content = r#"
1809# Test Document
1810
1811[Valid Link](exists.md)
1812[Invalid Link](missing.md)
1813[External Link](https://example.com)
1814[Media Link](image.jpg)
1815 "#;
1816
1817 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1819
1820 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1822 let result = rule.check(&ctx).unwrap();
1823
1824 assert_eq!(result.len(), 2);
1826 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
1827 assert!(messages.iter().any(|m| m.contains("missing.md")));
1828 assert!(messages.iter().any(|m| m.contains("image.jpg")));
1829 }
1830
1831 #[test]
1832 fn test_angle_bracket_links() {
1833 let temp_dir = tempdir().unwrap();
1835 let base_path = temp_dir.path();
1836
1837 let exists_path = base_path.join("exists.md");
1839 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
1840
1841 let content = r#"
1843# Test Document
1844
1845[Valid Link](<exists.md>)
1846[Invalid Link](<missing.md>)
1847[External Link](<https://example.com>)
1848 "#;
1849
1850 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1852
1853 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1854 let result = rule.check(&ctx).unwrap();
1855
1856 assert_eq!(result.len(), 1, "Should have exactly one warning");
1858 assert!(
1859 result[0].message.contains("missing.md"),
1860 "Warning should mention missing.md"
1861 );
1862 }
1863
1864 #[test]
1865 fn test_angle_bracket_links_with_parens() {
1866 let temp_dir = tempdir().unwrap();
1868 let base_path = temp_dir.path();
1869
1870 let app_dir = base_path.join("app");
1872 std::fs::create_dir(&app_dir).unwrap();
1873 let upload_dir = app_dir.join("(upload)");
1874 std::fs::create_dir(&upload_dir).unwrap();
1875 let page_file = upload_dir.join("page.tsx");
1876 File::create(&page_file)
1877 .unwrap()
1878 .write_all(b"export default function Page() {}")
1879 .unwrap();
1880
1881 let content = r#"
1883# Test Document with Paths Containing Parens
1884
1885[Upload Page](<app/(upload)/page.tsx>)
1886[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
1887[Missing](<app/(missing)/file.md>)
1888"#;
1889
1890 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1891
1892 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893 let result = rule.check(&ctx).unwrap();
1894
1895 assert_eq!(
1897 result.len(),
1898 1,
1899 "Should have exactly one warning for missing file. Got: {result:?}"
1900 );
1901 assert!(
1902 result[0].message.contains("app/(missing)/file.md"),
1903 "Warning should mention app/(missing)/file.md"
1904 );
1905 }
1906
1907 #[test]
1908 fn test_all_file_types_checked() {
1909 let temp_dir = tempdir().unwrap();
1911 let base_path = temp_dir.path();
1912
1913 let content = r#"
1915[Image Link](image.jpg)
1916[Video Link](video.mp4)
1917[Markdown Link](document.md)
1918[PDF Link](file.pdf)
1919"#;
1920
1921 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1922
1923 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1924 let result = rule.check(&ctx).unwrap();
1925
1926 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
1928 }
1929
1930 #[test]
1931 fn test_code_span_detection() {
1932 let rule = MD057ExistingRelativeLinks::new();
1933
1934 let temp_dir = tempdir().unwrap();
1936 let base_path = temp_dir.path();
1937
1938 let rule = rule.with_path(base_path);
1939
1940 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
1942
1943 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1944 let result = rule.check(&ctx).unwrap();
1945
1946 assert_eq!(result.len(), 1, "Should only flag the real link");
1948 assert!(result[0].message.contains("nonexistent.md"));
1949 }
1950
1951 #[test]
1952 fn test_inline_code_spans() {
1953 let temp_dir = tempdir().unwrap();
1955 let base_path = temp_dir.path();
1956
1957 let content = r#"
1959# Test Document
1960
1961This is a normal link: [Link](missing.md)
1962
1963This is a code span with a link: `[Link](another-missing.md)`
1964
1965Some more text with `inline code [Link](yet-another-missing.md) embedded`.
1966
1967 "#;
1968
1969 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
1971
1972 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1974 let result = rule.check(&ctx).unwrap();
1975
1976 assert_eq!(result.len(), 1, "Should have exactly one warning");
1978 assert!(
1979 result[0].message.contains("missing.md"),
1980 "Warning should be for missing.md"
1981 );
1982 assert!(
1983 !result.iter().any(|w| w.message.contains("another-missing.md")),
1984 "Should not warn about link in code span"
1985 );
1986 assert!(
1987 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
1988 "Should not warn about link in inline code"
1989 );
1990 }
1991
1992 #[test]
1993 fn test_extensionless_link_resolution() {
1994 let temp_dir = tempdir().unwrap();
1996 let base_path = temp_dir.path();
1997
1998 let page_path = base_path.join("page.md");
2000 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2001
2002 let content = r#"
2004# Test Document
2005
2006[Link without extension](page)
2007[Link with extension](page.md)
2008[Missing link](nonexistent)
2009"#;
2010
2011 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2012
2013 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014 let result = rule.check(&ctx).unwrap();
2015
2016 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2019 assert!(
2020 result[0].message.contains("nonexistent"),
2021 "Warning should be for 'nonexistent' not 'page'"
2022 );
2023 }
2024
2025 #[test]
2027 fn test_cross_file_scope() {
2028 let rule = MD057ExistingRelativeLinks::new();
2029 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2030 }
2031
2032 #[test]
2033 fn test_contribute_to_index_extracts_markdown_links() {
2034 let rule = MD057ExistingRelativeLinks::new();
2035 let content = r#"
2036# Document
2037
2038[Link to docs](./docs/guide.md)
2039[Link with fragment](./other.md#section)
2040[External link](https://example.com)
2041[Image link](image.png)
2042[Media file](video.mp4)
2043"#;
2044
2045 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2046 let mut index = FileIndex::new();
2047 rule.contribute_to_index(&ctx, &mut index);
2048
2049 assert_eq!(index.cross_file_links.len(), 2);
2051
2052 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2054 assert_eq!(index.cross_file_links[0].fragment, "");
2055
2056 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2058 assert_eq!(index.cross_file_links[1].fragment, "section");
2059 }
2060
2061 #[test]
2062 fn test_contribute_to_index_skips_external_and_anchors() {
2063 let rule = MD057ExistingRelativeLinks::new();
2064 let content = r#"
2065# Document
2066
2067[External](https://example.com)
2068[Another external](http://example.org)
2069[Fragment only](#section)
2070[FTP link](ftp://files.example.com)
2071[Mail link](mailto:test@example.com)
2072[WWW link](www.example.com)
2073"#;
2074
2075 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2076 let mut index = FileIndex::new();
2077 rule.contribute_to_index(&ctx, &mut index);
2078
2079 assert_eq!(index.cross_file_links.len(), 0);
2081 }
2082
2083 #[test]
2084 fn test_cross_file_check_valid_link() {
2085 use crate::workspace_index::WorkspaceIndex;
2086
2087 let rule = MD057ExistingRelativeLinks::new();
2088
2089 let mut workspace_index = WorkspaceIndex::new();
2091 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2092
2093 let mut file_index = FileIndex::new();
2095 file_index.add_cross_file_link(CrossFileLinkIndex {
2096 target_path: "guide.md".to_string(),
2097 fragment: "".to_string(),
2098 line: 5,
2099 column: 1,
2100 });
2101
2102 let warnings = rule
2104 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2105 .unwrap();
2106
2107 assert!(warnings.is_empty());
2109 }
2110
2111 #[test]
2112 fn test_cross_file_check_missing_link() {
2113 use crate::workspace_index::WorkspaceIndex;
2116
2117 let rule = MD057ExistingRelativeLinks::new();
2118 let workspace_index = WorkspaceIndex::new();
2119
2120 let mut file_index = FileIndex::new();
2121 file_index.add_cross_file_link(CrossFileLinkIndex {
2122 target_path: "missing.md".to_string(),
2123 fragment: "".to_string(),
2124 line: 5,
2125 column: 1,
2126 });
2127
2128 let warnings = rule
2129 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2130 .unwrap();
2131
2132 assert!(
2134 warnings.is_empty(),
2135 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2136 );
2137 }
2138
2139 #[test]
2140 fn test_cross_file_check_parent_path() {
2141 use crate::workspace_index::WorkspaceIndex;
2142
2143 let rule = MD057ExistingRelativeLinks::new();
2144
2145 let mut workspace_index = WorkspaceIndex::new();
2147 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2148
2149 let mut file_index = FileIndex::new();
2151 file_index.add_cross_file_link(CrossFileLinkIndex {
2152 target_path: "../readme.md".to_string(),
2153 fragment: "".to_string(),
2154 line: 5,
2155 column: 1,
2156 });
2157
2158 let warnings = rule
2160 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2161 .unwrap();
2162
2163 assert!(warnings.is_empty());
2165 }
2166
2167 #[test]
2168 fn test_cross_file_check_html_link_with_md_source() {
2169 use crate::workspace_index::WorkspaceIndex;
2172
2173 let rule = MD057ExistingRelativeLinks::new();
2174
2175 let mut workspace_index = WorkspaceIndex::new();
2177 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2178
2179 let mut file_index = FileIndex::new();
2181 file_index.add_cross_file_link(CrossFileLinkIndex {
2182 target_path: "guide.html".to_string(),
2183 fragment: "section".to_string(),
2184 line: 10,
2185 column: 5,
2186 });
2187
2188 let warnings = rule
2190 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2191 .unwrap();
2192
2193 assert!(
2195 warnings.is_empty(),
2196 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2197 );
2198 }
2199
2200 #[test]
2201 fn test_cross_file_check_html_link_without_source() {
2202 use crate::workspace_index::WorkspaceIndex;
2206
2207 let rule = MD057ExistingRelativeLinks::new();
2208 let workspace_index = WorkspaceIndex::new();
2209
2210 let mut file_index = FileIndex::new();
2211 file_index.add_cross_file_link(CrossFileLinkIndex {
2212 target_path: "missing.html".to_string(),
2213 fragment: "".to_string(),
2214 line: 10,
2215 column: 5,
2216 });
2217
2218 let warnings = rule
2219 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2220 .unwrap();
2221
2222 assert!(
2224 warnings.is_empty(),
2225 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_normalize_path_function() {
2231 assert_eq!(
2233 normalize_path(Path::new("docs/guide.md")),
2234 PathBuf::from("docs/guide.md")
2235 );
2236
2237 assert_eq!(
2239 normalize_path(Path::new("./docs/guide.md")),
2240 PathBuf::from("docs/guide.md")
2241 );
2242
2243 assert_eq!(
2245 normalize_path(Path::new("docs/sub/../guide.md")),
2246 PathBuf::from("docs/guide.md")
2247 );
2248
2249 assert_eq!(normalize_path(Path::new("a/b/c/../../d.md")), PathBuf::from("a/d.md"));
2251 }
2252
2253 #[test]
2254 fn test_html_link_with_md_source() {
2255 let temp_dir = tempdir().unwrap();
2257 let base_path = temp_dir.path();
2258
2259 let md_file = base_path.join("guide.md");
2261 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2262
2263 let content = r#"
2264[Read the guide](guide.html)
2265[Also here](getting-started.html)
2266"#;
2267
2268 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2269 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2270 let result = rule.check(&ctx).unwrap();
2271
2272 assert_eq!(
2274 result.len(),
2275 1,
2276 "Should only warn about missing source. Got: {result:?}"
2277 );
2278 assert!(result[0].message.contains("getting-started.html"));
2279 }
2280
2281 #[test]
2282 fn test_htm_link_with_md_source() {
2283 let temp_dir = tempdir().unwrap();
2285 let base_path = temp_dir.path();
2286
2287 let md_file = base_path.join("page.md");
2288 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2289
2290 let content = "[Page](page.htm)";
2291
2292 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2293 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2294 let result = rule.check(&ctx).unwrap();
2295
2296 assert!(
2297 result.is_empty(),
2298 "Should not warn when .md source exists for .htm link"
2299 );
2300 }
2301
2302 #[test]
2303 fn test_html_link_finds_various_markdown_extensions() {
2304 let temp_dir = tempdir().unwrap();
2306 let base_path = temp_dir.path();
2307
2308 File::create(base_path.join("doc.md")).unwrap();
2309 File::create(base_path.join("tutorial.mdx")).unwrap();
2310 File::create(base_path.join("guide.markdown")).unwrap();
2311
2312 let content = r#"
2313[Doc](doc.html)
2314[Tutorial](tutorial.html)
2315[Guide](guide.html)
2316"#;
2317
2318 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2319 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2320 let result = rule.check(&ctx).unwrap();
2321
2322 assert!(
2323 result.is_empty(),
2324 "Should find all markdown variants as source files. Got: {result:?}"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_html_link_in_subdirectory() {
2330 let temp_dir = tempdir().unwrap();
2332 let base_path = temp_dir.path();
2333
2334 let docs_dir = base_path.join("docs");
2335 std::fs::create_dir(&docs_dir).unwrap();
2336 File::create(docs_dir.join("guide.md"))
2337 .unwrap()
2338 .write_all(b"# Guide")
2339 .unwrap();
2340
2341 let content = "[Guide](docs/guide.html)";
2342
2343 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2344 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2345 let result = rule.check(&ctx).unwrap();
2346
2347 assert!(result.is_empty(), "Should find markdown source in subdirectory");
2348 }
2349
2350 #[test]
2351 fn test_absolute_path_skipped_in_check() {
2352 let temp_dir = tempdir().unwrap();
2355 let base_path = temp_dir.path();
2356
2357 let content = r#"
2358# Test Document
2359
2360[Go Runtime](/pkg/runtime)
2361[Go Runtime with Fragment](/pkg/runtime#section)
2362[API Docs](/api/v1/users)
2363[Blog Post](/blog/2024/release.html)
2364[React Hook](/react/hooks/use-state.html)
2365"#;
2366
2367 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2368 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2369 let result = rule.check(&ctx).unwrap();
2370
2371 assert!(
2373 result.is_empty(),
2374 "Absolute paths should be skipped. Got warnings: {result:?}"
2375 );
2376 }
2377
2378 #[test]
2379 fn test_absolute_path_skipped_in_cross_file_check() {
2380 use crate::workspace_index::WorkspaceIndex;
2382
2383 let rule = MD057ExistingRelativeLinks::new();
2384
2385 let workspace_index = WorkspaceIndex::new();
2387
2388 let mut file_index = FileIndex::new();
2390 file_index.add_cross_file_link(CrossFileLinkIndex {
2391 target_path: "/pkg/runtime.md".to_string(),
2392 fragment: "".to_string(),
2393 line: 5,
2394 column: 1,
2395 });
2396 file_index.add_cross_file_link(CrossFileLinkIndex {
2397 target_path: "/api/v1/users.md".to_string(),
2398 fragment: "section".to_string(),
2399 line: 10,
2400 column: 1,
2401 });
2402
2403 let warnings = rule
2405 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2406 .unwrap();
2407
2408 assert!(
2410 warnings.is_empty(),
2411 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
2412 );
2413 }
2414
2415 #[test]
2416 fn test_protocol_relative_url_not_skipped() {
2417 let temp_dir = tempdir().unwrap();
2420 let base_path = temp_dir.path();
2421
2422 let content = r#"
2423# Test Document
2424
2425[External](//example.com/page)
2426[Another](//cdn.example.com/asset.js)
2427"#;
2428
2429 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2430 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2431 let result = rule.check(&ctx).unwrap();
2432
2433 assert!(
2435 result.is_empty(),
2436 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
2437 );
2438 }
2439
2440 #[test]
2441 fn test_email_addresses_skipped() {
2442 let temp_dir = tempdir().unwrap();
2445 let base_path = temp_dir.path();
2446
2447 let content = r#"
2448# Test Document
2449
2450[Contact](user@example.com)
2451[Steering](steering@kubernetes.io)
2452[Support](john.doe+filter@company.co.uk)
2453[User](user_name@sub.domain.com)
2454"#;
2455
2456 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2457 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2458 let result = rule.check(&ctx).unwrap();
2459
2460 assert!(
2462 result.is_empty(),
2463 "Email addresses should be skipped. Got warnings: {result:?}"
2464 );
2465 }
2466
2467 #[test]
2468 fn test_email_addresses_vs_file_paths() {
2469 let temp_dir = tempdir().unwrap();
2472 let base_path = temp_dir.path();
2473
2474 let content = r#"
2475# Test Document
2476
2477[Email](user@example.com) <!-- Should be skipped (email) -->
2478[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
2479[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
2480"#;
2481
2482 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2483 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2484 let result = rule.check(&ctx).unwrap();
2485
2486 assert!(
2488 result.is_empty(),
2489 "All email addresses should be skipped. Got: {result:?}"
2490 );
2491 }
2492
2493 #[test]
2494 fn test_diagnostic_position_accuracy() {
2495 let temp_dir = tempdir().unwrap();
2497 let base_path = temp_dir.path();
2498
2499 let content = "prefix [text](missing.md) suffix";
2502 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2506 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2507 let result = rule.check(&ctx).unwrap();
2508
2509 assert_eq!(result.len(), 1, "Should have exactly one warning");
2510 assert_eq!(result[0].line, 1, "Should be on line 1");
2511 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
2512 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
2513 }
2514
2515 #[test]
2516 fn test_diagnostic_position_non_ascii_link() {
2517 let temp_dir = tempdir().unwrap();
2520 let base_path = temp_dir.path();
2521
2522 let content = "你好你好[你好](not-exist.md) bar";
2526
2527 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2528 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2529 let result = rule.check(&ctx).unwrap();
2530
2531 assert_eq!(result.len(), 1, "Should have exactly one warning");
2532 assert_eq!(result[0].line, 1, "Should be on line 1");
2533 assert_eq!(
2534 result[0].column, 10,
2535 "Column must be a character offset, not a byte offset"
2536 );
2537 assert_eq!(result[0].end_column, 22, "End column must be character-based");
2538 }
2539
2540 #[test]
2541 fn test_diagnostic_position_angle_brackets() {
2542 let temp_dir = tempdir().unwrap();
2544 let base_path = temp_dir.path();
2545
2546 let content = "[link](<missing.md>)";
2549 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2552 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2553 let result = rule.check(&ctx).unwrap();
2554
2555 assert_eq!(result.len(), 1, "Should have exactly one warning");
2556 assert_eq!(result[0].line, 1, "Should be on line 1");
2557 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
2558 }
2559
2560 #[test]
2561 fn test_diagnostic_position_multiline() {
2562 let temp_dir = tempdir().unwrap();
2564 let base_path = temp_dir.path();
2565
2566 let content = r#"# Title
2567Some text on line 2
2568[link on line 3](missing1.md)
2569More text
2570[link on line 5](missing2.md)"#;
2571
2572 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2573 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2574 let result = rule.check(&ctx).unwrap();
2575
2576 assert_eq!(result.len(), 2, "Should have two warnings");
2577
2578 assert_eq!(result[0].line, 3, "First warning should be on line 3");
2580 assert!(result[0].message.contains("missing1.md"));
2581
2582 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
2584 assert!(result[1].message.contains("missing2.md"));
2585 }
2586
2587 #[test]
2588 fn test_diagnostic_position_with_spaces() {
2589 let temp_dir = tempdir().unwrap();
2591 let base_path = temp_dir.path();
2592
2593 let content = "[link]( missing.md )";
2594 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2599 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2600 let result = rule.check(&ctx).unwrap();
2601
2602 assert_eq!(result.len(), 1, "Should have exactly one warning");
2603 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
2605 }
2606
2607 #[test]
2608 fn test_diagnostic_position_image() {
2609 let temp_dir = tempdir().unwrap();
2611 let base_path = temp_dir.path();
2612
2613 let content = "";
2614
2615 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2616 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2617 let result = rule.check(&ctx).unwrap();
2618
2619 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2620 assert_eq!(result[0].line, 1);
2621 assert!(result[0].column > 0, "Should have valid column position");
2623 assert!(result[0].message.contains("missing.jpg"));
2624 }
2625
2626 #[test]
2627 fn test_diagnostic_position_non_ascii_image() {
2628 let temp_dir = tempdir().unwrap();
2630 let base_path = temp_dir.path();
2631
2632 let content = "你好你好";
2635
2636 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2637 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2638 let result = rule.check(&ctx).unwrap();
2639
2640 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
2641 assert_eq!(result[0].line, 1, "Should be on line 1");
2642 assert_eq!(
2643 result[0].column, 5,
2644 "Column must be a character offset, not a byte offset"
2645 );
2646 assert!(result[0].message.contains("not-exist.png"));
2647 }
2648
2649 #[test]
2650 fn test_diagnostic_position_non_ascii_reference_def() {
2651 let temp_dir = tempdir().unwrap();
2655 let base_path = temp_dir.path();
2656
2657 let content = "[你好]: not-exist.md";
2660
2661 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2662 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2663 let result = rule.check(&ctx).unwrap();
2664
2665 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
2666 assert_eq!(result[0].line, 1, "Should be on line 1");
2667 assert_eq!(
2668 result[0].column, 7,
2669 "Column must be a character offset, not a byte offset"
2670 );
2671 assert_eq!(result[0].end_column, 19, "End column must be character-based");
2672 }
2673
2674 #[test]
2675 fn test_wikilinks_skipped() {
2676 let temp_dir = tempdir().unwrap();
2679 let base_path = temp_dir.path();
2680
2681 let content = r#"# Test Document
2682
2683[[Microsoft#Windows OS]]
2684[[SomePage]]
2685[[Page With Spaces]]
2686[[path/to/page#section]]
2687[[page|Display Text]]
2688
2689This is a [real missing link](missing.md) that should be flagged.
2690"#;
2691
2692 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2693 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2694 let result = rule.check(&ctx).unwrap();
2695
2696 assert_eq!(
2698 result.len(),
2699 1,
2700 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
2701 );
2702 assert!(
2703 result[0].message.contains("missing.md"),
2704 "Warning should be for missing.md, not wikilinks"
2705 );
2706 }
2707
2708 #[test]
2709 fn test_wikilinks_not_added_to_index() {
2710 let temp_dir = tempdir().unwrap();
2712 let base_path = temp_dir.path();
2713
2714 let content = r#"# Test Document
2715
2716[[Microsoft#Windows OS]]
2717[[SomePage#section]]
2718[Regular Link](other.md)
2719"#;
2720
2721 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2722 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2723
2724 let mut file_index = FileIndex::new();
2725 rule.contribute_to_index(&ctx, &mut file_index);
2726
2727 let cross_file_links = &file_index.cross_file_links;
2730 assert_eq!(
2731 cross_file_links.len(),
2732 1,
2733 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
2734 );
2735 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
2736 }
2737
2738 #[test]
2739 fn test_reference_definition_missing_file() {
2740 let temp_dir = tempdir().unwrap();
2742 let base_path = temp_dir.path();
2743
2744 let content = r#"# Test Document
2745
2746[test]: ./missing.md
2747[example]: ./nonexistent.html
2748
2749Use [test] and [example] here.
2750"#;
2751
2752 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2753 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2754 let result = rule.check(&ctx).unwrap();
2755
2756 assert_eq!(
2758 result.len(),
2759 2,
2760 "Should have warnings for missing reference definition targets. Got: {result:?}"
2761 );
2762 assert!(
2763 result.iter().any(|w| w.message.contains("missing.md")),
2764 "Should warn about missing.md"
2765 );
2766 assert!(
2767 result.iter().any(|w| w.message.contains("nonexistent.html")),
2768 "Should warn about nonexistent.html"
2769 );
2770 }
2771
2772 #[test]
2773 fn test_reference_definition_existing_file() {
2774 let temp_dir = tempdir().unwrap();
2776 let base_path = temp_dir.path();
2777
2778 let exists_path = base_path.join("exists.md");
2780 File::create(&exists_path)
2781 .unwrap()
2782 .write_all(b"# Existing file")
2783 .unwrap();
2784
2785 let content = r#"# Test Document
2786
2787[test]: ./exists.md
2788
2789Use [test] here.
2790"#;
2791
2792 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2793 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2794 let result = rule.check(&ctx).unwrap();
2795
2796 assert!(
2798 result.is_empty(),
2799 "Should not warn about existing file. Got: {result:?}"
2800 );
2801 }
2802
2803 #[test]
2804 fn test_reference_definition_external_url_skipped() {
2805 let temp_dir = tempdir().unwrap();
2807 let base_path = temp_dir.path();
2808
2809 let content = r#"# Test Document
2810
2811[google]: https://google.com
2812[example]: http://example.org
2813[mail]: mailto:test@example.com
2814[ftp]: ftp://files.example.com
2815[local]: ./missing.md
2816
2817Use [google], [example], [mail], [ftp], [local] here.
2818"#;
2819
2820 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2821 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2822 let result = rule.check(&ctx).unwrap();
2823
2824 assert_eq!(
2826 result.len(),
2827 1,
2828 "Should only warn about local missing file. Got: {result:?}"
2829 );
2830 assert!(
2831 result[0].message.contains("missing.md"),
2832 "Warning should be for missing.md"
2833 );
2834 }
2835
2836 #[test]
2837 fn test_reference_definition_fragment_only_skipped() {
2838 let temp_dir = tempdir().unwrap();
2840 let base_path = temp_dir.path();
2841
2842 let content = r#"# Test Document
2843
2844[section]: #my-section
2845
2846Use [section] here.
2847"#;
2848
2849 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2850 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2851 let result = rule.check(&ctx).unwrap();
2852
2853 assert!(
2855 result.is_empty(),
2856 "Should not warn about fragment-only reference. Got: {result:?}"
2857 );
2858 }
2859
2860 #[test]
2861 fn test_reference_definition_column_position() {
2862 let temp_dir = tempdir().unwrap();
2864 let base_path = temp_dir.path();
2865
2866 let content = "[ref]: ./missing.md";
2869 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2874 let result = rule.check(&ctx).unwrap();
2875
2876 assert_eq!(result.len(), 1, "Should have exactly one warning");
2877 assert_eq!(result[0].line, 1, "Should be on line 1");
2878 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
2879 }
2880
2881 #[test]
2882 fn test_reference_definition_html_with_md_source() {
2883 let temp_dir = tempdir().unwrap();
2885 let base_path = temp_dir.path();
2886
2887 let md_file = base_path.join("guide.md");
2889 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2890
2891 let content = r#"# Test Document
2892
2893[guide]: ./guide.html
2894[missing]: ./missing.html
2895
2896Use [guide] and [missing] here.
2897"#;
2898
2899 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2900 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2901 let result = rule.check(&ctx).unwrap();
2902
2903 assert_eq!(
2905 result.len(),
2906 1,
2907 "Should only warn about missing source. Got: {result:?}"
2908 );
2909 assert!(result[0].message.contains("missing.html"));
2910 }
2911
2912 #[test]
2913 fn test_reference_definition_url_encoded() {
2914 let temp_dir = tempdir().unwrap();
2916 let base_path = temp_dir.path();
2917
2918 let file_with_spaces = base_path.join("file with spaces.md");
2920 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
2921
2922 let content = r#"# Test Document
2923
2924[spaces]: ./file%20with%20spaces.md
2925[missing]: ./missing%20file.md
2926
2927Use [spaces] and [missing] here.
2928"#;
2929
2930 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2931 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2932 let result = rule.check(&ctx).unwrap();
2933
2934 assert_eq!(
2936 result.len(),
2937 1,
2938 "Should only warn about missing URL-encoded file. Got: {result:?}"
2939 );
2940 assert!(result[0].message.contains("missing%20file.md"));
2941 }
2942
2943 #[test]
2944 fn test_inline_and_reference_both_checked() {
2945 let temp_dir = tempdir().unwrap();
2947 let base_path = temp_dir.path();
2948
2949 let content = r#"# Test Document
2950
2951[inline link](./inline-missing.md)
2952[ref]: ./ref-missing.md
2953
2954Use [ref] here.
2955"#;
2956
2957 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2958 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2959 let result = rule.check(&ctx).unwrap();
2960
2961 assert_eq!(
2963 result.len(),
2964 2,
2965 "Should warn about both inline and reference links. Got: {result:?}"
2966 );
2967 assert!(
2968 result.iter().any(|w| w.message.contains("inline-missing.md")),
2969 "Should warn about inline-missing.md"
2970 );
2971 assert!(
2972 result.iter().any(|w| w.message.contains("ref-missing.md")),
2973 "Should warn about ref-missing.md"
2974 );
2975 }
2976
2977 #[test]
2978 fn test_footnote_definitions_not_flagged() {
2979 let rule = MD057ExistingRelativeLinks::default();
2982
2983 let content = r#"# Title
2984
2985A footnote[^1].
2986
2987[^1]: [link](https://www.google.com).
2988"#;
2989
2990 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2991 let result = rule.check(&ctx).unwrap();
2992
2993 assert!(
2994 result.is_empty(),
2995 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
2996 );
2997 }
2998
2999 #[test]
3000 fn test_footnote_with_relative_link_inside() {
3001 let rule = MD057ExistingRelativeLinks::default();
3004
3005 let content = r#"# Title
3006
3007See the footnote[^1].
3008
3009[^1]: Check out [this file](./existing.md) for more info.
3010[^2]: Also see [missing](./does-not-exist.md).
3011"#;
3012
3013 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3014 let result = rule.check(&ctx).unwrap();
3015
3016 for warning in &result {
3021 assert!(
3022 !warning.message.contains("[this file]"),
3023 "Footnote content should not be treated as URL: {warning:?}"
3024 );
3025 assert!(
3026 !warning.message.contains("[missing]"),
3027 "Footnote content should not be treated as URL: {warning:?}"
3028 );
3029 }
3030 }
3031
3032 #[test]
3033 fn test_mixed_footnotes_and_reference_definitions() {
3034 let temp_dir = tempdir().unwrap();
3036 let base_path = temp_dir.path();
3037
3038 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3039
3040 let content = r#"# Title
3041
3042A footnote[^1] and a [ref link][myref].
3043
3044[^1]: This is a footnote with [link](https://example.com).
3045
3046[myref]: ./missing-file.md "This should be checked"
3047"#;
3048
3049 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3050 let result = rule.check(&ctx).unwrap();
3051
3052 assert_eq!(
3054 result.len(),
3055 1,
3056 "Should only warn about the regular reference definition. Got: {result:?}"
3057 );
3058 assert!(
3059 result[0].message.contains("missing-file.md"),
3060 "Should warn about missing-file.md in reference definition"
3061 );
3062 }
3063
3064 #[test]
3065 fn test_absolute_links_ignore_by_default() {
3066 let temp_dir = tempdir().unwrap();
3068 let base_path = temp_dir.path();
3069
3070 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3071
3072 let content = r#"# Links
3073
3074[API docs](/api/v1/users)
3075[Blog post](/blog/2024/release.html)
3076
3077
3078[ref]: /docs/reference.md
3079"#;
3080
3081 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3082 let result = rule.check(&ctx).unwrap();
3083
3084 assert!(
3086 result.is_empty(),
3087 "Absolute links should be ignored by default. Got: {result:?}"
3088 );
3089 }
3090
3091 #[test]
3092 fn test_absolute_links_warn_config() {
3093 let temp_dir = tempdir().unwrap();
3095 let base_path = temp_dir.path();
3096
3097 let config = MD057Config {
3098 absolute_links: AbsoluteLinksOption::Warn,
3099 ..Default::default()
3100 };
3101 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3102
3103 let content = r#"# Links
3104
3105[API docs](/api/v1/users)
3106[Blog post](/blog/2024/release.html)
3107"#;
3108
3109 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3110 let result = rule.check(&ctx).unwrap();
3111
3112 assert_eq!(
3114 result.len(),
3115 2,
3116 "Should warn about both absolute links. Got: {result:?}"
3117 );
3118 assert!(
3119 result[0].message.contains("cannot be validated locally"),
3120 "Warning should explain why: {}",
3121 result[0].message
3122 );
3123 assert!(
3124 result[0].message.contains("/api/v1/users"),
3125 "Warning should include the link path"
3126 );
3127 }
3128
3129 #[test]
3130 fn test_absolute_links_warn_images() {
3131 let temp_dir = tempdir().unwrap();
3133 let base_path = temp_dir.path();
3134
3135 let config = MD057Config {
3136 absolute_links: AbsoluteLinksOption::Warn,
3137 ..Default::default()
3138 };
3139 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3140
3141 let content = r#"# Images
3142
3143
3144"#;
3145
3146 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3147 let result = rule.check(&ctx).unwrap();
3148
3149 assert_eq!(
3150 result.len(),
3151 1,
3152 "Should warn about absolute image path. Got: {result:?}"
3153 );
3154 assert!(
3155 result[0].message.contains("/assets/logo.png"),
3156 "Warning should include the image path"
3157 );
3158 }
3159
3160 #[test]
3161 fn test_absolute_links_warn_reference_definitions() {
3162 let temp_dir = tempdir().unwrap();
3164 let base_path = temp_dir.path();
3165
3166 let config = MD057Config {
3167 absolute_links: AbsoluteLinksOption::Warn,
3168 ..Default::default()
3169 };
3170 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3171
3172 let content = r#"# Reference
3173
3174See the [docs][ref].
3175
3176[ref]: /docs/reference.md
3177"#;
3178
3179 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3180 let result = rule.check(&ctx).unwrap();
3181
3182 assert_eq!(
3183 result.len(),
3184 1,
3185 "Should warn about absolute reference definition. Got: {result:?}"
3186 );
3187 assert!(
3188 result[0].message.contains("/docs/reference.md"),
3189 "Warning should include the reference path"
3190 );
3191 }
3192
3193 #[test]
3194 fn test_search_paths_inline_link() {
3195 let temp_dir = tempdir().unwrap();
3196 let base_path = temp_dir.path();
3197
3198 let assets_dir = base_path.join("assets");
3200 std::fs::create_dir_all(&assets_dir).unwrap();
3201 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3202
3203 let config = MD057Config {
3204 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3205 ..Default::default()
3206 };
3207 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3208
3209 let content = "# Test\n\n[Photo](photo.png)\n";
3210 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3211 let result = rule.check(&ctx).unwrap();
3212
3213 assert!(
3214 result.is_empty(),
3215 "Should find photo.png via search-paths. Got: {result:?}"
3216 );
3217 }
3218
3219 #[test]
3220 fn test_search_paths_image() {
3221 let temp_dir = tempdir().unwrap();
3222 let base_path = temp_dir.path();
3223
3224 let assets_dir = base_path.join("attachments");
3225 std::fs::create_dir_all(&assets_dir).unwrap();
3226 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3227
3228 let config = MD057Config {
3229 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3230 ..Default::default()
3231 };
3232 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3233
3234 let content = "# Test\n\n\n";
3235 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3236 let result = rule.check(&ctx).unwrap();
3237
3238 assert!(
3239 result.is_empty(),
3240 "Should find diagram.svg via search-paths. Got: {result:?}"
3241 );
3242 }
3243
3244 #[test]
3245 fn test_search_paths_reference_definition() {
3246 let temp_dir = tempdir().unwrap();
3247 let base_path = temp_dir.path();
3248
3249 let assets_dir = base_path.join("images");
3250 std::fs::create_dir_all(&assets_dir).unwrap();
3251 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3252
3253 let config = MD057Config {
3254 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3255 ..Default::default()
3256 };
3257 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3258
3259 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3260 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3261 let result = rule.check(&ctx).unwrap();
3262
3263 assert!(
3264 result.is_empty(),
3265 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3266 );
3267 }
3268
3269 #[test]
3270 fn test_search_paths_still_warns_when_truly_missing() {
3271 let temp_dir = tempdir().unwrap();
3272 let base_path = temp_dir.path();
3273
3274 let assets_dir = base_path.join("assets");
3275 std::fs::create_dir_all(&assets_dir).unwrap();
3276
3277 let config = MD057Config {
3278 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3279 ..Default::default()
3280 };
3281 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3282
3283 let content = "# Test\n\n\n";
3284 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3285 let result = rule.check(&ctx).unwrap();
3286
3287 assert_eq!(
3288 result.len(),
3289 1,
3290 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
3291 );
3292 }
3293
3294 #[test]
3295 fn test_search_paths_nonexistent_directory() {
3296 let temp_dir = tempdir().unwrap();
3297 let base_path = temp_dir.path();
3298
3299 let config = MD057Config {
3300 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
3301 ..Default::default()
3302 };
3303 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3304
3305 let content = "# Test\n\n\n";
3306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3307 let result = rule.check(&ctx).unwrap();
3308
3309 assert_eq!(
3310 result.len(),
3311 1,
3312 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
3313 );
3314 }
3315
3316 #[test]
3317 fn test_obsidian_attachment_folder_named() {
3318 let temp_dir = tempdir().unwrap();
3319 let vault = temp_dir.path().join("vault");
3320 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3321 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3322 std::fs::create_dir_all(vault.join("notes")).unwrap();
3323
3324 std::fs::write(
3325 vault.join(".obsidian/app.json"),
3326 r#"{"attachmentFolderPath": "Attachments"}"#,
3327 )
3328 .unwrap();
3329 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3330
3331 let notes_dir = vault.join("notes");
3332 let source_file = notes_dir.join("test.md");
3333 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
3334
3335 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3336
3337 let content = "# Test\n\n\n";
3338 let ctx =
3339 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3340 let result = rule.check(&ctx).unwrap();
3341
3342 assert!(
3343 result.is_empty(),
3344 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
3345 );
3346 }
3347
3348 #[test]
3349 fn test_obsidian_attachment_same_folder_as_file() {
3350 let temp_dir = tempdir().unwrap();
3351 let vault = temp_dir.path().join("vault-rf");
3352 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3353 std::fs::create_dir_all(vault.join("notes")).unwrap();
3354
3355 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
3356
3357 let notes_dir = vault.join("notes");
3359 let source_file = notes_dir.join("test.md");
3360 std::fs::write(&source_file, "placeholder").unwrap();
3361 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
3362
3363 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3364
3365 let content = "# Test\n\n\n";
3366 let ctx =
3367 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3368 let result = rule.check(&ctx).unwrap();
3369
3370 assert!(
3371 result.is_empty(),
3372 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
3373 );
3374 }
3375
3376 #[test]
3377 fn test_obsidian_not_triggered_without_obsidian_flavor() {
3378 let temp_dir = tempdir().unwrap();
3379 let vault = temp_dir.path().join("vault-nf");
3380 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3381 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3382 std::fs::create_dir_all(vault.join("notes")).unwrap();
3383
3384 std::fs::write(
3385 vault.join(".obsidian/app.json"),
3386 r#"{"attachmentFolderPath": "Attachments"}"#,
3387 )
3388 .unwrap();
3389 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3390
3391 let notes_dir = vault.join("notes");
3392 let source_file = notes_dir.join("test.md");
3393 std::fs::write(&source_file, "placeholder").unwrap();
3394
3395 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3396
3397 let content = "# Test\n\n\n";
3398 let ctx =
3400 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
3401 let result = rule.check(&ctx).unwrap();
3402
3403 assert_eq!(
3404 result.len(),
3405 1,
3406 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
3407 );
3408 }
3409
3410 #[test]
3411 fn test_search_paths_combined_with_obsidian() {
3412 let temp_dir = tempdir().unwrap();
3413 let vault = temp_dir.path().join("vault-combo");
3414 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3415 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3416 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
3417 std::fs::create_dir_all(vault.join("notes")).unwrap();
3418
3419 std::fs::write(
3420 vault.join(".obsidian/app.json"),
3421 r#"{"attachmentFolderPath": "Attachments"}"#,
3422 )
3423 .unwrap();
3424 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
3425 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
3426
3427 let notes_dir = vault.join("notes");
3428 let source_file = notes_dir.join("test.md");
3429 std::fs::write(&source_file, "placeholder").unwrap();
3430
3431 let extra_assets_dir = vault.join("extra-assets");
3432 let config = MD057Config {
3433 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
3434 ..Default::default()
3435 };
3436 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
3437
3438 let content = "# Test\n\n\n\n\n";
3440 let ctx =
3441 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3442 let result = rule.check(&ctx).unwrap();
3443
3444 assert!(
3445 result.is_empty(),
3446 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
3447 );
3448 }
3449
3450 #[test]
3451 fn test_obsidian_attachment_subfolder_under_file() {
3452 let temp_dir = tempdir().unwrap();
3453 let vault = temp_dir.path().join("vault-sub");
3454 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3455 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
3456
3457 std::fs::write(
3458 vault.join(".obsidian/app.json"),
3459 r#"{"attachmentFolderPath": "./assets"}"#,
3460 )
3461 .unwrap();
3462 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
3463
3464 let notes_dir = vault.join("notes");
3465 let source_file = notes_dir.join("test.md");
3466 std::fs::write(&source_file, "placeholder").unwrap();
3467
3468 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3469
3470 let content = "# Test\n\n\n";
3471 let ctx =
3472 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3473 let result = rule.check(&ctx).unwrap();
3474
3475 assert!(
3476 result.is_empty(),
3477 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
3478 );
3479 }
3480
3481 #[test]
3482 fn test_obsidian_attachment_vault_root() {
3483 let temp_dir = tempdir().unwrap();
3484 let vault = temp_dir.path().join("vault-root");
3485 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3486 std::fs::create_dir_all(vault.join("notes")).unwrap();
3487
3488 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
3490 std::fs::write(vault.join("photo.png"), "fake").unwrap();
3491
3492 let notes_dir = vault.join("notes");
3493 let source_file = notes_dir.join("test.md");
3494 std::fs::write(&source_file, "placeholder").unwrap();
3495
3496 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
3497
3498 let content = "# Test\n\n\n";
3499 let ctx =
3500 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
3501 let result = rule.check(&ctx).unwrap();
3502
3503 assert!(
3504 result.is_empty(),
3505 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
3506 );
3507 }
3508
3509 #[test]
3510 fn test_search_paths_multiple_directories() {
3511 let temp_dir = tempdir().unwrap();
3512 let base_path = temp_dir.path();
3513
3514 let dir_a = base_path.join("dir-a");
3515 let dir_b = base_path.join("dir-b");
3516 std::fs::create_dir_all(&dir_a).unwrap();
3517 std::fs::create_dir_all(&dir_b).unwrap();
3518 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
3519 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
3520
3521 let config = MD057Config {
3522 search_paths: vec![
3523 dir_a.to_string_lossy().into_owned(),
3524 dir_b.to_string_lossy().into_owned(),
3525 ],
3526 ..Default::default()
3527 };
3528 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3529
3530 let content = "# Test\n\n\n\n\n";
3531 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3532 let result = rule.check(&ctx).unwrap();
3533
3534 assert!(
3535 result.is_empty(),
3536 "Should find files across multiple search paths. Got: {result:?}"
3537 );
3538 }
3539
3540 #[test]
3541 fn test_cross_file_check_with_search_paths() {
3542 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3543
3544 let temp_dir = tempdir().unwrap();
3545 let base_path = temp_dir.path();
3546
3547 let docs_dir = base_path.join("docs");
3549 std::fs::create_dir_all(&docs_dir).unwrap();
3550 std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap();
3551
3552 let config = MD057Config {
3553 search_paths: vec![docs_dir.to_string_lossy().into_owned()],
3554 ..Default::default()
3555 };
3556 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3557
3558 let file_path = base_path.join("README.md");
3559 std::fs::write(&file_path, "# Readme\n").unwrap();
3560
3561 let mut file_index = FileIndex::default();
3562 file_index.cross_file_links.push(CrossFileLinkIndex {
3563 target_path: "guide.md".to_string(),
3564 fragment: String::new(),
3565 line: 3,
3566 column: 1,
3567 });
3568
3569 let workspace_index = WorkspaceIndex::new();
3570
3571 let result = rule
3572 .cross_file_check(&file_path, &file_index, &workspace_index)
3573 .unwrap();
3574
3575 assert!(
3576 result.is_empty(),
3577 "cross_file_check should find guide.md via search-paths. Got: {result:?}"
3578 );
3579 }
3580
3581 #[test]
3582 fn test_cross_file_check_with_obsidian_flavor() {
3583 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex};
3584
3585 let temp_dir = tempdir().unwrap();
3586 let vault = temp_dir.path().join("vault-xf");
3587 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
3588 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
3589 std::fs::create_dir_all(vault.join("notes")).unwrap();
3590
3591 std::fs::write(
3592 vault.join(".obsidian/app.json"),
3593 r#"{"attachmentFolderPath": "Attachments"}"#,
3594 )
3595 .unwrap();
3596 std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap();
3597
3598 let notes_dir = vault.join("notes");
3599 let file_path = notes_dir.join("test.md");
3600 std::fs::write(&file_path, "placeholder").unwrap();
3601
3602 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default())
3603 .with_path(¬es_dir)
3604 .with_flavor(crate::config::MarkdownFlavor::Obsidian);
3605
3606 let mut file_index = FileIndex::default();
3607 file_index.cross_file_links.push(CrossFileLinkIndex {
3608 target_path: "ref.md".to_string(),
3609 fragment: String::new(),
3610 line: 3,
3611 column: 1,
3612 });
3613
3614 let workspace_index = WorkspaceIndex::new();
3615
3616 let result = rule
3617 .cross_file_check(&file_path, &file_index, &workspace_index)
3618 .unwrap();
3619
3620 assert!(
3621 result.is_empty(),
3622 "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}"
3623 );
3624 }
3625
3626 #[test]
3627 fn test_check_clears_stale_cache() {
3628 let temp_dir = tempdir().unwrap();
3631 let base_path = temp_dir.path();
3632
3633 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3634
3635 let phantom_path = base_path.join("phantom.md");
3637 {
3638 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3639 cache.insert(phantom_path.clone(), true);
3640 }
3641
3642 let content = "[phantom](phantom.md)\n";
3643 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3644 let warnings = rule.check(&ctx).unwrap();
3645
3646 assert_eq!(
3648 warnings.len(),
3649 1,
3650 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
3651 );
3652 assert!(warnings[0].message.contains("phantom.md"));
3653 }
3654
3655 #[test]
3656 fn test_check_does_not_carry_over_cache_between_runs() {
3657 let temp_dir = tempdir().unwrap();
3659 let base_path = temp_dir.path();
3660
3661 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3662
3663 let content = "[missing](nonexistent.md)\n";
3664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3665
3666 let warnings_1 = rule.check(&ctx).unwrap();
3668 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
3669
3670 let nonexistent_path = base_path.join("nonexistent.md");
3672 {
3673 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
3674 cache.insert(nonexistent_path.clone(), true);
3675 }
3676
3677 let warnings_2 = rule.check(&ctx).unwrap();
3679 assert_eq!(
3680 warnings_2.len(),
3681 1,
3682 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
3683 );
3684 }
3685
3686 #[test]
3692 fn test_no_duplicate_warnings_for_broken_relative_link() {
3693 use crate::workspace_index::WorkspaceIndex;
3694
3695 let temp_dir = tempdir().unwrap();
3696 let base_path = temp_dir.path();
3697
3698 let source_file = base_path.join("index.md");
3700 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
3701
3702 let content = "[broken](does/not/exist.md)\n";
3703
3704 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3705
3706 let ctx = crate::lint_context::LintContext::new(
3708 content,
3709 crate::config::MarkdownFlavor::Standard,
3710 Some(source_file.clone()),
3711 );
3712 let check_warnings = rule.check(&ctx).unwrap();
3713
3714 let mut file_index = FileIndex::new();
3716 rule.contribute_to_index(&ctx, &mut file_index);
3717 let workspace_index = WorkspaceIndex::new();
3718 let cross_warnings = rule
3719 .cross_file_check(&source_file, &file_index, &workspace_index)
3720 .unwrap();
3721
3722 let total = check_warnings.len() + cross_warnings.len();
3723 assert_eq!(
3724 total, 1,
3725 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
3726 check={check_warnings:?}, cross={cross_warnings:?}"
3727 );
3728 }
3729
3730 #[test]
3735 fn test_absolute_dir_link_accepted_relative_to_roots() {
3736 let temp_dir = tempdir().unwrap();
3737 let root = temp_dir.path();
3738
3739 let dir_d = root.join("d");
3741 std::fs::create_dir_all(&dir_d).unwrap();
3742 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3743
3744 let content = "\
3747[absolute dir](/d)\n\
3748[relative dir](d)\n\
3749[absolute file](/d/foo.md)\n\
3750[relative file](d/foo.md)\n";
3751
3752 let config = MD057Config {
3753 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3754 roots: vec![],
3755 ..Default::default()
3756 };
3757 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3758
3759 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3760 let result = rule.check(&ctx).unwrap();
3761
3762 assert!(
3763 result.is_empty(),
3764 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
3765 );
3766 }
3767
3768 #[test]
3771 fn test_absolute_trailing_slash_dir_link_requires_index() {
3772 let temp_dir = tempdir().unwrap();
3773 let root = temp_dir.path();
3774
3775 let dir_d = root.join("d");
3777 std::fs::create_dir_all(&dir_d).unwrap();
3778 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
3779
3780 let content = "[dir with slash](/d/)\n";
3782
3783 let config = MD057Config {
3784 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3785 roots: vec![],
3786 ..Default::default()
3787 };
3788 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3789
3790 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3791 let result = rule.check(&ctx).unwrap();
3792
3793 assert_eq!(
3794 result.len(),
3795 1,
3796 "Trailing-slash directory link without index.md must be flagged. Got: {result:?}"
3797 );
3798 }
3799
3800 #[test]
3804 fn test_docs_dir_variant_still_enforces_index_md() {
3805 let temp_dir = tempdir().unwrap();
3806 let root = temp_dir.path();
3807
3808 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
3810
3811 let docs_dir = root.join("docs");
3813 std::fs::create_dir_all(&docs_dir).unwrap();
3814 let section_dir = docs_dir.join("section");
3815 std::fs::create_dir_all(§ion_dir).unwrap();
3816 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
3817
3818 let source_file = docs_dir.join("index.md");
3820 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
3821
3822 let config = MD057Config {
3823 absolute_links: AbsoluteLinksOption::RelativeToDocs,
3824 ..Default::default()
3825 };
3826 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
3827
3828 let content = "[sec](/section)\n";
3829 let ctx = crate::lint_context::LintContext::new(
3830 content,
3831 crate::config::MarkdownFlavor::Standard,
3832 Some(source_file.clone()),
3833 );
3834 let result = rule.check(&ctx).unwrap();
3835
3836 assert_eq!(
3838 result.len(),
3839 1,
3840 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
3841 );
3842 assert!(
3843 result[0].message.contains("index.md") || result[0].message.contains("section"),
3844 "Message should mention the directory or missing index.md: {}",
3845 result[0].message
3846 );
3847 }
3848
3849 #[test]
3855 fn test_trailing_slash_with_fragment_treated_as_directory_link() {
3856 let temp_dir = tempdir().unwrap();
3857 let root = temp_dir.path();
3858
3859 let guide_dir = root.join("guide");
3861 std::fs::create_dir_all(&guide_dir).unwrap();
3862 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
3863
3864 let content = "[guide with fragment](/guide/#intro)\n";
3866
3867 let config = MD057Config {
3868 absolute_links: AbsoluteLinksOption::RelativeToRoots,
3869 roots: vec![],
3870 ..Default::default()
3871 };
3872 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
3873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3874 let result = rule.check(&ctx).unwrap();
3875
3876 assert_eq!(
3877 result.len(),
3878 1,
3879 "Trailing-slash link with fragment and no index.md must be flagged. Got: {result:?}"
3880 );
3881 }
3882}