1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12 FileIndex, LinkOrigin, Md057LinkTarget, URL_EXTRACT_ANGLE_BRACKET_REGEX, URL_EXTRACT_REGEX,
13 extract_cross_file_links, normalize_relative_path,
14};
15use pulldown_cmark::LinkType;
16use regex::Regex;
17use std::borrow::Cow;
18use std::collections::{HashMap, HashSet};
19use std::env;
20use std::ffi::{OsStr, OsString};
21use std::path::{Component, Path, PathBuf};
22use std::sync::LazyLock;
23use std::sync::{Arc, Mutex};
24use std::time::SystemTime;
25use unicode_normalization::UnicodeNormalization;
26
27mod md057_config;
28use crate::utils::mkdocs_config::resolve_docs_dir;
29use crate::utils::obsidian_config::resolve_attachment_folder;
30use crate::utils::project_root::project_root;
31pub use md057_config::{AbsoluteLinksOption, MD057Config};
32
33static FILE_EXISTENCE_CACHE: LazyLock<Arc<Mutex<HashMap<PathBuf, bool>>>> =
35 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
36
37type DirectoryListingCache = Arc<Mutex<HashMap<PathBuf, Arc<DirectoryListing>>>>;
40
41static DIRECTORY_LISTING_CACHE: LazyLock<DirectoryListingCache> =
45 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
46
47fn reset_file_existence_cache() {
54 if let Ok(mut cache) = FILE_EXISTENCE_CACHE.lock() {
55 cache.clear();
56 }
57}
58
59struct DirectoryListing {
62 modified: Option<SystemTime>,
66 listed: bool,
69 names: HashSet<OsString>,
71 composed: HashSet<String>,
75}
76
77impl DirectoryListing {
78 fn unlisted(modified: Option<SystemTime>) -> Self {
81 Self {
82 modified,
83 listed: false,
84 names: HashSet::new(),
85 composed: HashSet::new(),
86 }
87 }
88
89 fn read(directory: &Path) -> Self {
90 let modified = std::fs::metadata(directory)
94 .and_then(|metadata| metadata.modified())
95 .ok();
96 let mut names = HashSet::new();
97 let mut composed = HashSet::new();
98 let Ok(entries) = std::fs::read_dir(directory) else {
99 return Self::unlisted(modified);
100 };
101 for entry in entries {
102 let Ok(entry) = entry else {
103 return Self::unlisted(modified);
107 };
108 let name = entry.file_name();
109 if let Some(text) = name.to_str()
110 && !text.is_ascii()
111 {
112 composed.insert(text.nfc().collect());
113 }
114 names.insert(name);
115 }
116 Self {
117 modified,
118 listed: true,
119 names,
120 composed,
121 }
122 }
123
124 fn holds(&self, name: &OsStr) -> bool {
131 if !self.listed {
132 return true;
133 }
134 if self.names.contains(name) {
135 return true;
136 }
137 let Some(text) = name.to_str() else {
138 return false;
139 };
140 if text.is_ascii() {
141 return self.composed.contains(text);
142 }
143 let composed: String = text.nfc().collect();
144 self.composed.contains(composed.as_str()) || self.names.contains(OsStr::new(composed.as_str()))
145 }
146}
147
148fn directory_listing(directory: &Path) -> Arc<DirectoryListing> {
165 let Ok(modified) = std::fs::metadata(directory).and_then(|metadata| metadata.modified()) else {
166 return Arc::new(DirectoryListing::read(directory));
169 };
170 match DIRECTORY_LISTING_CACHE.lock() {
171 Ok(cache) => {
172 if let Some(listing) = cache.get(directory)
173 && listing.modified == Some(modified)
174 {
175 return Arc::clone(listing);
176 }
177 }
178 Err(_) => return Arc::new(DirectoryListing::read(directory)), }
180 let listing = Arc::new(DirectoryListing::read(directory));
181 match DIRECTORY_LISTING_CACHE.lock() {
182 Ok(mut cache) => {
183 cache.insert(directory.to_path_buf(), Arc::clone(&listing));
190 listing
191 }
192 Err(_) => listing, }
194}
195
196fn file_exists_with_cache(path: &Path) -> bool {
198 match FILE_EXISTENCE_CACHE.lock() {
199 Ok(mut cache) => *cache.entry(path.to_path_buf()).or_insert_with(|| path.exists()),
200 Err(_) => path.exists(), }
202}
203
204fn has_exact_case_components(anchor: &Path, path: &Path) -> bool {
217 let Ok(relative) = path.strip_prefix(anchor) else {
218 return true;
219 };
220 let mut directory = if anchor.as_os_str().is_empty() {
221 PathBuf::from(".")
222 } else {
223 anchor.to_path_buf()
224 };
225 for component in relative.components() {
226 match component {
227 Component::CurDir => {}
228 Component::ParentDir => {
229 if matches!(directory.components().next_back(), Some(Component::Normal(_))) {
233 directory.pop();
234 } else {
235 directory.push("..");
236 }
237 if directory.as_os_str().is_empty() {
240 directory.push(".");
241 }
242 }
243 Component::Normal(name) => {
244 if !directory_listing(&directory).holds(name) {
245 return false;
246 }
247 directory.push(name);
248 }
249 Component::RootDir | Component::Prefix(_) => return true,
252 }
253 }
254 true
255}
256
257fn exists_exact_case(anchor: &Path, path: &Path) -> bool {
266 file_exists_with_cache(path) && has_exact_case_components(anchor, path)
267}
268
269fn file_exists_or_markdown_extension(anchor: &Path, path: &Path) -> bool {
272 resolve_existing_target(anchor, path).is_some()
273}
274
275fn resolve_existing_target(anchor: &Path, path: &Path) -> Option<PathBuf> {
282 if exists_exact_case(anchor, path) {
284 return Some(path.to_path_buf());
285 }
286
287 if path.extension().is_none() {
289 for ext in MARKDOWN_EXTENSIONS {
290 let path_with_ext = path.with_extension(&ext[1..]);
292 if exists_exact_case(anchor, &path_with_ext) {
293 return Some(path_with_ext);
294 }
295 }
296 }
297
298 None
299}
300
301static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
305 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
306
307static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
309
310#[inline]
313fn hex_digit_to_value(byte: u8) -> Option<u8> {
314 match byte {
315 b'0'..=b'9' => Some(byte - b'0'),
316 b'a'..=b'f' => Some(byte - b'a' + 10),
317 b'A'..=b'F' => Some(byte - b'A' + 10),
318 _ => None,
319 }
320}
321
322const MARKDOWN_EXTENSIONS: &[&str] = &[
324 ".md",
325 ".markdown",
326 ".mdx",
327 ".mkd",
328 ".mkdn",
329 ".mdown",
330 ".mdwn",
331 ".qmd",
332 ".rmd",
333];
334
335#[derive(Debug, PartialEq, Eq)]
337enum SelfReferentialLink {
338 WholeFile,
341 Fragment(String),
344}
345
346#[cfg(feature = "blake3")]
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348enum DependencyPathState {
349 Missing,
350 File,
351 Directory,
352 Other,
353}
354
355#[derive(Debug, Clone)]
357pub struct MD057ExistingRelativeLinks {
358 base_path: Arc<Mutex<Option<PathBuf>>>,
363 config: MD057Config,
365}
366
367impl Default for MD057ExistingRelativeLinks {
368 fn default() -> Self {
369 Self {
370 base_path: Arc::new(Mutex::new(None)),
371 config: MD057Config::default(),
372 }
373 }
374}
375
376impl MD057ExistingRelativeLinks {
377 pub fn new() -> Self {
379 Self::default()
380 }
381
382 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
384 let path = path.as_ref();
385 let dir_path = if path.is_file() {
386 path.parent().map(std::path::Path::to_path_buf)
387 } else {
388 Some(path.to_path_buf())
389 };
390
391 if let Ok(mut guard) = self.base_path.lock() {
392 *guard = dir_path;
393 }
394 self
395 }
396
397 pub fn from_config_struct(config: MD057Config) -> Self {
398 Self {
399 base_path: Arc::new(Mutex::new(None)),
400 config,
401 }
402 }
403
404 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
408 if Path::new(path_str).is_absolute() {
409 PathBuf::from(path_str)
410 } else {
411 project_root.join(path_str)
412 }
413 }
414
415 #[inline]
427 fn is_external_url(&self, url: &str) -> bool {
428 if url.is_empty() {
429 return false;
430 }
431
432 if PROTOCOL_DOMAIN_REGEX.is_match(url) || url.starts_with("www.") {
434 return true;
435 }
436
437 if url.starts_with("{{") || url.starts_with("{%") {
440 return true;
441 }
442
443 if url.contains('@') {
446 return true; }
448
449 if !url.contains('/') && url.ends_with(".com") {
459 return true;
460 }
461
462 if url.starts_with('~') || url.starts_with('@') {
466 return true;
467 }
468
469 false
471 }
472
473 #[inline]
476 fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
477 self.is_external_url(url)
478 || (flavor == crate::config::MarkdownFlavor::GhAw && crate::utils::gh_aw::is_output_placeholder(url))
479 }
480
481 #[inline]
483 fn is_fragment_only_link(&self, url: &str) -> bool {
484 url.starts_with('#')
485 }
486
487 #[inline]
490 fn is_absolute_path(url: &str) -> bool {
491 url.starts_with('/')
492 }
493
494 fn url_decode(path: &str) -> String {
498 if !path.contains('%') {
500 return path.to_string();
501 }
502
503 let bytes = path.as_bytes();
504 let mut result = Vec::with_capacity(bytes.len());
505 let mut i = 0;
506
507 while i < bytes.len() {
508 if bytes[i] == b'%' && i + 2 < bytes.len() {
509 let hex1 = bytes[i + 1];
511 let hex2 = bytes[i + 2];
512 if let (Some(d1), Some(d2)) = (hex_digit_to_value(hex1), hex_digit_to_value(hex2)) {
513 result.push(d1 * 16 + d2);
514 i += 3;
515 continue;
516 }
517 }
518 result.push(bytes[i]);
519 i += 1;
520 }
521
522 String::from_utf8(result).unwrap_or_else(|_| path.to_string())
524 }
525
526 fn strip_query_and_fragment(url: &str) -> &str {
534 let query_pos = url.find('?');
537 let fragment_pos = url.find('#');
538
539 match (query_pos, fragment_pos) {
540 (Some(q), Some(f)) => {
541 &url[..q.min(f)]
543 }
544 (Some(q), None) => &url[..q],
545 (None, Some(f)) => &url[..f],
546 (None, None) => url,
547 }
548 }
549
550 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
552 base_path.join(link)
553 }
554
555 fn compute_search_paths(
560 &self,
561 flavor: crate::config::MarkdownFlavor,
562 source_file: Option<&Path>,
563 base_path: &Path,
564 project_root: &Path,
565 ) -> Vec<PathBuf> {
566 let mut paths = Vec::new();
567
568 if flavor == crate::config::MarkdownFlavor::Obsidian
570 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
571 && attachment_dir != *base_path
572 {
573 paths.push(attachment_dir);
574 }
575
576 for search_path in &self.config.search_paths {
580 let resolved = Self::resolve_against_project_root(search_path, project_root);
581 if resolved != *base_path && !paths.contains(&resolved) {
582 paths.push(resolved);
583 }
584 }
585
586 paths
587 }
588
589 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
594 for destination in body_link_destinations(ctx) {
595 let url = destination.url;
596 if url.is_empty()
597 || (url.starts_with('`') && url.ends_with('`'))
598 || self.is_non_file_destination(url, ctx.flavor)
599 || self.is_fragment_only_link(url)
600 {
601 continue;
602 }
603 index.add_md057_link_target(Md057LinkTarget {
604 target: url.to_string(),
605 origin: LinkOrigin::Body,
606 });
607 }
608
609 for image in ctx.images() {
610 if ctx
611 .line_info(image.line)
612 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
613 || matches!(image.link_type, LinkType::WikiLink { .. })
614 || ctx.is_in_shortcode(image.byte_offset)
615 {
616 continue;
617 }
618 let url = image.url.as_ref();
619 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
620 continue;
621 }
622 index.add_md057_link_target(Md057LinkTarget {
623 target: url.to_string(),
624 origin: LinkOrigin::Body,
625 });
626 }
627
628 for reference in ctx.reference_definitions() {
629 if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
630 continue;
631 }
632 let url = reference.url.as_str();
633 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
634 continue;
635 }
636 index.add_md057_link_target(Md057LinkTarget {
637 target: url.to_string(),
638 origin: LinkOrigin::Body,
639 });
640 }
641
642 for link in frontmatter_values::link_destinations(ctx) {
643 let line = ctx.lines[link.line - 1].content(ctx.content);
644 let url = &line[link.range];
645 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
646 continue;
647 }
648 index.add_md057_link_target(Md057LinkTarget {
649 target: url.to_string(),
650 origin: LinkOrigin::FrontMatter { field: link.field },
651 });
652 }
653 }
654
655 fn exists_in_search_paths(
657 decoded_path: &str,
658 search_paths: &[PathBuf],
659 policy: Option<&crate::lint_context::LinkTargetPolicy>,
660 ) -> bool {
661 search_paths.iter().any(|dir| {
662 let candidate = dir.join(decoded_path);
663 Self::target_exists(dir, &candidate, policy)
664 })
665 }
666
667 fn target_exists(anchor: &Path, path: &Path, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> bool {
668 Self::resolve_target(anchor, path, policy).is_some()
669 }
670
671 fn resolve_target(
676 anchor: &Path,
677 path: &Path,
678 policy: Option<&crate::lint_context::LinkTargetPolicy>,
679 ) -> Option<PathBuf> {
680 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
681 return Some(supplied);
682 }
683 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
684 return None;
685 }
686 resolve_existing_target(anchor, path)
687 }
688
689 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
690 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
691 format!("Relative link '{url}' target not in the supplied document set")
692 } else {
693 format!("Relative link '{url}' does not exist")
694 }
695 }
696
697 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
703 if !self.config.compact_paths {
704 return None;
705 }
706
707 let path_end = url
709 .find('?')
710 .unwrap_or(url.len())
711 .min(url.find('#').unwrap_or(url.len()));
712 let path_part = &url[..path_end];
713 let suffix = &url[path_end..];
714
715 let decoded_path = Self::url_decode(path_part);
717
718 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
719 }
720
721 fn self_referential_link(
733 &self,
734 url: &str,
735 base_path: &Path,
736 search_paths: &[PathBuf],
737 source_file: Option<&Path>,
738 policy: Option<&crate::lint_context::LinkTargetPolicy>,
739 ) -> Option<SelfReferentialLink> {
740 if !self.config.self_referential_links {
741 return None;
742 }
743 let source_file = source_file?;
744
745 let path_part = Self::strip_query_and_fragment(url);
746 if path_part.is_empty() {
747 return None;
748 }
749 let suffix = &url[path_part.len()..];
750
751 let decoded_path = Self::url_decode(path_part);
752 let resolved = std::iter::once(base_path)
756 .chain(search_paths.iter().map(PathBuf::as_path))
757 .find_map(|dir| {
758 Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
759 })?;
760 if !Self::is_same_file(&resolved, source_file) {
761 return None;
762 }
763
764 match suffix.strip_prefix('#') {
768 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
769 _ => Some(SelfReferentialLink::WholeFile),
770 }
771 }
772
773 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
780 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
781 let label_end = Self::label_end(def)?;
782 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
783 title.saturating_sub(ref_def.byte_offset).min(def.len())
784 });
785 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
786 let start = ref_def.byte_offset + offset;
787 Some(start..start + ref_def.url.len())
788 }
789
790 fn label_end(def: &str) -> Option<usize> {
795 let bytes = def.as_bytes();
796 let mut i = 0;
797 while i < bytes.len() {
798 match bytes[i] {
799 b'\\' => i += 2,
800 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
801 _ => i += 1,
802 }
803 }
804 None
805 }
806
807 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
811 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
812 }
813
814 fn absolute_link_message(&self, url: &str, base_path: &Path, project_root: &Path) -> Option<String> {
817 match self.config.absolute_links {
818 AbsoluteLinksOption::Ignore => None,
819 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
820 AbsoluteLinksOption::RelativeToDocs => Self::validate_absolute_link_via_docs_dir(url, base_path),
821 AbsoluteLinksOption::RelativeToRoots => {
822 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root)
823 }
824 }
825 }
826
827 fn check_front_matter(
834 &self,
835 ctx: &crate::lint_context::LintContext,
836 base_path: &Path,
837 search_paths: &[PathBuf],
838 project_root: &Path,
839 warnings: &mut Vec<LintWarning>,
840 ) {
841 if !self.config.check_frontmatter {
842 return;
843 }
844
845 let ignored: HashSet<String> = self
846 .config
847 .ignore_frontmatter_fields
848 .iter()
849 .map(|field| field.to_lowercase())
850 .collect();
851
852 for link in frontmatter_values::link_destinations(ctx) {
853 if link.field_is_in(&ignored) {
854 continue;
855 }
856
857 let line = ctx.lines[link.line - 1].content(ctx.content);
858 let url = &line[link.range.clone()];
859
860 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
863 continue;
864 }
865
866 let column = byte_to_char_count(line, link.range.start);
867 let end_column = column + url.chars().count();
868
869 if Self::is_absolute_path(url) {
870 if let Some(message) = self.absolute_link_message(url, base_path, project_root) {
871 warnings.push(LintWarning {
872 rule_name: Some(self.name().to_string()),
873 line: link.line,
874 column,
875 end_line: link.line,
876 end_column,
877 message,
878 severity: Severity::Warning,
879 fix: None,
880 });
881 }
882 continue;
883 }
884
885 if Self::relative_target_exists(url, base_path, search_paths, ctx.link_target_policy()) {
886 continue;
887 }
888
889 warnings.push(LintWarning {
890 rule_name: Some(self.name().to_string()),
891 line: link.line,
892 column,
893 end_line: link.line,
894 end_column,
895 message: Self::missing_relative_message(url, ctx.link_target_policy()),
896 severity: Severity::Error,
897 fix: None,
898 });
899 }
900 }
901
902 fn relative_target_exists(
909 url: &str,
910 base_path: &Path,
911 search_paths: &[PathBuf],
912 policy: Option<&crate::lint_context::LinkTargetPolicy>,
913 ) -> bool {
914 let decoded_path = Self::url_decode(Self::strip_query_and_fragment(url));
915 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
916
917 if Self::target_exists(base_path, &resolved_path, policy) {
919 return true;
920 }
921
922 if let Some(ext) = resolved_path.extension().and_then(|e| e.to_str())
923 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
924 && let (Some(stem), Some(parent)) = (
925 resolved_path.file_stem().and_then(|s| s.to_str()),
926 resolved_path.parent(),
927 )
928 && MARKDOWN_EXTENSIONS
929 .iter()
930 .any(|md_ext| Self::target_exists(base_path, &parent.join(format!("{stem}{md_ext}")), policy))
931 {
932 return true;
933 }
934
935 Self::exists_in_search_paths(&decoded_path, search_paths, policy)
936 }
937
938 fn produces_fixes(&self) -> bool {
942 self.config.compact_paths || self.config.self_referential_links
943 }
944
945 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
947 match self_link {
948 SelfReferentialLink::Fragment(fragment) => {
949 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
950 }
951 SelfReferentialLink::WholeFile => {
952 format!("Relative link '{url}' points to the file it is in")
953 }
954 }
955 }
956
957 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
963 if resolved.file_name() != source_file.file_name() {
965 return false;
966 }
967 match (resolved.canonicalize(), source_file.canonicalize()) {
968 (Ok(link), Ok(source)) => link == source,
969 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
970 }
971 }
972
973 fn validate_absolute_link_via_docs_dir(url: &str, source_path: &Path) -> Option<String> {
979 let Some(docs_dir) = resolve_docs_dir(source_path) else {
980 return Some(format!(
981 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
982 ));
983 };
984
985 let decoded = Self::prepare_absolute_url(url);
986
987 match Self::resolve_under_root_with_opts(&docs_dir, &decoded, true) {
990 Resolution::Found => None,
991 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
992 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
993 resolved.display()
994 )),
995 Resolution::NotFound { resolved } => Some(format!(
996 "Absolute link '{url}' resolves to '{}' which does not exist",
997 resolved.display()
998 )),
999 }
1000 }
1001
1002 fn validate_absolute_link_via_roots(url: &str, roots: &[String], project_root: &Path) -> Option<String> {
1011 let decoded = Self::prepare_absolute_url(url);
1012
1013 for root in roots {
1014 let root_path = Self::resolve_against_project_root(root, project_root);
1015 if matches!(
1018 Self::resolve_under_root_with_opts(&root_path, &decoded, false),
1019 Resolution::Found
1020 ) {
1021 return None;
1022 }
1023 }
1024
1025 if matches!(
1026 Self::resolve_under_root_with_opts(project_root, &decoded, false),
1028 Resolution::Found
1029 ) {
1030 return None;
1031 }
1032
1033 let msg = if roots.is_empty() {
1034 format!("Absolute link '{url}' was not found under the project root")
1035 } else {
1036 format!("Absolute link '{url}' was not found under any configured root or the project root")
1037 };
1038 Some(msg)
1039 }
1040
1041 fn prepare_absolute_url(url: &str) -> String {
1044 let relative_url = url.trim_start_matches('/');
1045 let file_path = Self::strip_query_and_fragment(relative_url);
1046 Self::url_decode(file_path)
1047 }
1048
1049 fn resolve_under_root_with_opts(root_path: &Path, decoded: &str, require_index_for_dirs: bool) -> Resolution {
1076 let resolved = root_path.join(decoded);
1077
1078 if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1079 if !require_index_for_dirs {
1080 return Resolution::Found;
1081 }
1082 return if exists_exact_case(root_path, &resolved.join("index.md")) {
1083 Resolution::Found
1084 } else {
1085 Resolution::DirectoryWithoutIndex { resolved }
1086 };
1087 }
1088
1089 if file_exists_or_markdown_extension(root_path, &resolved) {
1090 return Resolution::Found;
1091 }
1092
1093 if let Some(ext) = resolved.extension().and_then(|e| e.to_str())
1096 && (ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
1097 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|s| s.to_str()), resolved.parent())
1098 {
1099 let has_md_source = MARKDOWN_EXTENSIONS.iter().any(|md_ext| {
1100 let source_path = parent.join(format!("{stem}{md_ext}"));
1101 exists_exact_case(root_path, &source_path)
1102 });
1103 if has_md_source {
1104 return Resolution::Found;
1105 }
1106 }
1107
1108 Resolution::NotFound { resolved }
1109 }
1110}
1111
1112#[cfg(feature = "blake3")]
1116impl MD057ExistingRelativeLinks {
1117 pub fn cache_dependency_fingerprint(
1123 &self,
1124 source_file: &Path,
1125 flavor: crate::config::MarkdownFlavor,
1126 file_index: &FileIndex,
1127 ) -> String {
1128 let mut hasher = blake3::Hasher::new();
1129 hasher.update(b"rumdl-md057-dependencies-v1");
1130 if file_index.md057_link_targets.is_empty() {
1131 return hasher.finalize().to_hex().to_string();
1132 }
1133
1134 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1135 let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1136 let resolved_source = source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf());
1137 let base_path = explicit_base.unwrap_or_else(|| {
1138 resolved_source
1139 .parent()
1140 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
1141 });
1142 let search_paths = self.compute_search_paths(flavor, Some(source_file), &base_path, &project_root);
1143 let ignored_frontmatter_fields: HashSet<String> = self
1144 .config
1145 .ignore_frontmatter_fields
1146 .iter()
1147 .map(|field| field.to_lowercase())
1148 .collect();
1149
1150 for dependency in &file_index.md057_link_targets {
1151 if let LinkOrigin::FrontMatter { field } = &dependency.origin
1152 && (!self.config.check_frontmatter
1153 || field
1154 .as_ref()
1155 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1156 {
1157 continue;
1158 }
1159
1160 let url = dependency.target.as_str();
1161 if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1162 continue;
1163 }
1164
1165 Self::hash_bytes(&mut hasher, url.as_bytes());
1166 if Self::is_absolute_path(url) {
1167 match self.config.absolute_links {
1168 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1169 AbsoluteLinksOption::RelativeToDocs => {
1170 hasher.update(b"docs");
1171 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1172 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1173 } else {
1174 hasher.update(b"no-docs-dir");
1175 }
1176 }
1177 AbsoluteLinksOption::RelativeToRoots => {
1178 hasher.update(b"roots");
1179 let decoded = Self::prepare_absolute_url(url);
1180 let mut found = false;
1181 for root in &self.config.roots {
1182 let root_path = Self::resolve_against_project_root(root, &project_root);
1183 if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1184 found = true;
1185 break;
1186 }
1187 }
1188 if !found {
1189 Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1190 }
1191 }
1192 }
1193 } else {
1194 hasher.update(b"relative");
1195 if self.config.self_referential_links
1196 && Self::observe_self_referential_resolution(
1197 &mut hasher,
1198 url,
1199 &base_path,
1200 &search_paths,
1201 &resolved_source,
1202 )
1203 {
1204 continue;
1205 }
1206 Self::observe_relative_resolution(&mut hasher, url, &base_path, &search_paths);
1207 }
1208 }
1209
1210 hasher.finalize().to_hex().to_string()
1211 }
1212
1213 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1214 hasher.update(&(bytes.len() as u64).to_le_bytes());
1215 hasher.update(bytes);
1216 }
1217
1218 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1219 #[cfg(unix)]
1220 {
1221 use std::os::unix::ffi::OsStrExt;
1222 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1223 }
1224 #[cfg(windows)]
1225 {
1226 use std::os::windows::ffi::OsStrExt;
1227 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1228 Self::hash_bytes(hasher, &encoded);
1229 }
1230 #[cfg(not(any(unix, windows)))]
1231 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1232 }
1233
1234 fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1239 Self::hash_path(hasher, path);
1240 let state = if exists_exact_case(anchor, path) {
1241 match std::fs::metadata(path) {
1242 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1243 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1244 Ok(_) => DependencyPathState::Other,
1245 Err(_) => DependencyPathState::Missing,
1246 }
1247 } else {
1248 DependencyPathState::Missing
1249 };
1250 hasher.update(&[match state {
1251 DependencyPathState::Missing => 0,
1252 DependencyPathState::File => 1,
1253 DependencyPathState::Directory => 2,
1254 DependencyPathState::Other => 3,
1255 }]);
1256 state
1257 }
1258
1259 fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1260 if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1261 return Some(path.to_path_buf());
1262 }
1263 if path.extension().is_none() {
1264 for extension in MARKDOWN_EXTENSIONS {
1265 let candidate = path.with_extension(&extension[1..]);
1266 if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1267 return Some(candidate);
1268 }
1269 }
1270 }
1271 None
1272 }
1273
1274 fn observe_self_referential_resolution(
1275 hasher: &mut blake3::Hasher,
1276 url: &str,
1277 base_path: &Path,
1278 search_paths: &[PathBuf],
1279 source_file: &Path,
1280 ) -> bool {
1281 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1282 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1283 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1284 if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1285 let canonical = resolved.canonicalize().unwrap_or(resolved);
1286 hasher.update(b"resolved-identity");
1287 Self::hash_path(hasher, &canonical);
1288 return Self::is_same_file(&canonical, source_file);
1289 }
1290 }
1291 false
1292 }
1293
1294 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1295 let decoded = Self::url_decode(Self::strip_query_and_fragment(url));
1296 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1297 if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1298 return;
1299 }
1300
1301 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1302 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1303 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1304 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1305 Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1306 != DependencyPathState::Missing
1307 })
1308 {
1309 return;
1310 }
1311
1312 for search_path in search_paths {
1313 if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1314 return;
1315 }
1316 }
1317 }
1318
1319 fn observe_absolute_resolution(
1320 hasher: &mut blake3::Hasher,
1321 root: &Path,
1322 url: &str,
1323 require_index_for_dirs: bool,
1324 ) -> bool {
1325 let decoded = Self::prepare_absolute_url(url);
1326 Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1327 }
1328
1329 fn observe_under_root(
1333 hasher: &mut blake3::Hasher,
1334 root: &Path,
1335 decoded: &str,
1336 require_index_for_dirs: bool,
1337 ) -> bool {
1338 let resolved = root.join(decoded);
1339 let resolved_state = Self::observe_path(hasher, root, &resolved);
1340
1341 if resolved_state == DependencyPathState::Directory {
1342 if !require_index_for_dirs {
1343 return true;
1344 }
1345 return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1346 }
1347
1348 if resolved_state != DependencyPathState::Missing {
1349 return true;
1350 }
1351 if resolved.extension().is_none()
1352 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1353 Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1354 != DependencyPathState::Missing
1355 })
1356 {
1357 return true;
1358 }
1359
1360 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1361 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1362 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1363 {
1364 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1365 Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1366 != DependencyPathState::Missing
1367 });
1368 }
1369
1370 false
1371 }
1372}
1373
1374enum Resolution {
1378 Found,
1379 DirectoryWithoutIndex { resolved: PathBuf },
1380 NotFound { resolved: PathBuf },
1381}
1382
1383fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1394 let caps = re.captures_at(line, expected_start)?;
1395 if caps.get(0)?.start() != expected_start {
1396 return None;
1397 }
1398 Some(caps)
1399}
1400
1401struct BodyLinkDestination<'a> {
1403 url: &'a str,
1406 fragment: &'a str,
1409 url_range: std::ops::Range<usize>,
1411 fix_range: std::ops::Range<usize>,
1414}
1415
1416impl<'a> BodyLinkDestination<'a> {
1417 fn full_url(&self) -> Cow<'a, str> {
1420 if self.fragment.is_empty() {
1421 Cow::Borrowed(self.url)
1422 } else {
1423 Cow::Owned(format!("{}{}", self.url, self.fragment))
1424 }
1425 }
1426}
1427
1428fn body_link_destinations<'ctx>(
1446 ctx: &'ctx crate::lint_context::LintContext<'_>,
1447) -> impl Iterator<Item = BodyLinkDestination<'ctx>> + 'ctx {
1448 let mut pending_images = ctx.images().iter().peekable();
1456 let mut open_images: Vec<&crate::lint_context::ParsedImage<'_>> = Vec::new();
1457 let mut previous_link_offset = 0usize;
1458 ctx.links().iter().filter_map(move |link| {
1459 debug_assert!(
1460 link.byte_offset >= previous_link_offset,
1461 "the links arrive sorted by start offset, which is what lets one forward pass over the images find the ones around each link"
1462 );
1463 previous_link_offset = link.byte_offset;
1464 if !matches!(link.link_type, LinkType::Inline) {
1465 return None;
1466 }
1467 if ctx
1468 .line_info(link.line)
1469 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1470 {
1471 return None;
1472 }
1473 if ctx.is_in_code_span_byte(link.byte_offset)
1474 || ctx.is_in_math_span(link.byte_offset)
1475 || ctx.is_in_shortcode(link.byte_offset)
1476 {
1477 return None;
1478 }
1479 while let Some(image) = pending_images.next_if(|image| image.byte_offset <= link.byte_offset) {
1485 close_images_ending_by(&mut open_images, image.byte_offset);
1486 open_images.push(image);
1487 }
1488 close_images_ending_by(&mut open_images, link.byte_offset);
1489 if open_images
1490 .iter()
1491 .any(|image| link.byte_end <= image.byte_end && renders_as_image(ctx, image))
1492 {
1493 return None;
1494 }
1495 locate_destination(ctx.content, link)
1496 })
1497}
1498
1499fn close_images_ending_by(open_images: &mut Vec<&crate::lint_context::ParsedImage<'_>>, offset: usize) {
1503 while open_images.last().is_some_and(|image| image.byte_end <= offset) {
1504 open_images.pop();
1505 }
1506}
1507
1508fn renders_as_image(ctx: &crate::lint_context::LintContext<'_>, image: &crate::lint_context::ParsedImage<'_>) -> bool {
1516 if !image.is_reference {
1517 return true;
1518 }
1519 image
1520 .reference_id
1521 .as_ref()
1522 .is_some_and(|id| ctx.reference_definition(id).is_some())
1523}
1524
1525fn locate_destination<'a>(
1539 content: &'a str,
1540 link: &crate::lint_context::ParsedLink<'_>,
1541) -> Option<BodyLinkDestination<'a>> {
1542 let span = content.get(link.byte_offset..link.byte_end)?;
1543 let anchor = 1 + link.text.len();
1544 if !span.get(anchor..).is_some_and(|rest| rest.starts_with("](")) {
1545 return None;
1546 }
1547 let caps = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, span, anchor)
1548 .or_else(|| extract_url_at(&URL_EXTRACT_REGEX, span, anchor))?;
1549 let url_group = caps.get(1)?;
1550 let fragment = caps.get(2);
1551
1552 let span_start = link.byte_offset;
1553 let url_range = span_start + url_group.start()..span_start + url_group.end();
1554 let fix_end = fragment.map_or(url_range.end, |group| span_start + group.end());
1555
1556 Some(BodyLinkDestination {
1557 url: url_group.as_str().trim(),
1558 fragment: fragment.map_or("", |group| group.as_str()),
1559 fix_range: url_range.start..fix_end,
1560 url_range,
1561 })
1562}
1563
1564impl Rule for MD057ExistingRelativeLinks {
1565 fn name(&self) -> &'static str {
1566 "MD057"
1567 }
1568
1569 fn description(&self) -> &'static str {
1570 "Relative links should point to existing files"
1571 }
1572
1573 fn category(&self) -> RuleCategory {
1574 RuleCategory::Link
1575 }
1576
1577 fn skippable_by_category(&self) -> bool {
1578 !self.config.check_frontmatter
1581 }
1582
1583 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1584 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1585 }
1586
1587 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1588 let content = ctx.content;
1589
1590 if content.is_empty() {
1591 return Ok(Vec::new());
1592 }
1593
1594 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1598 if !has_body_links && !self.checks_front_matter_of(ctx) {
1599 return Ok(Vec::new());
1600 }
1601
1602 reset_file_existence_cache();
1604
1605 let mut warnings = Vec::new();
1606
1607 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1611
1612 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1616
1617 let self_path: Option<PathBuf> = ctx
1620 .source_file()
1621 .map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
1622
1623 let base_path: Option<PathBuf> = {
1627 if explicit_base.is_some() {
1628 explicit_base
1629 } else if let Some(ref resolved_file) = self_path {
1630 resolved_file
1634 .parent()
1635 .map(std::path::Path::to_path_buf)
1636 .or_else(|| Some(CURRENT_DIR.clone()))
1637 } else {
1638 None
1640 }
1641 };
1642
1643 let Some(base_path) = base_path else {
1645 return Ok(warnings);
1646 };
1647
1648 let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file(), &base_path, &project_root);
1650
1651 for destination in body_link_destinations(ctx) {
1656 let url = destination.url;
1657
1658 if url.is_empty() {
1660 continue;
1661 }
1662
1663 if url.starts_with('`') && url.ends_with('`') {
1667 continue;
1668 }
1669
1670 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1672 continue;
1673 }
1674
1675 if Self::is_absolute_path(url) {
1677 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1678 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1679 warnings.push(LintWarning {
1680 rule_name: Some(self.name().to_string()),
1681 line,
1682 column,
1683 end_line: line,
1684 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1685 message,
1686 severity: Severity::Warning,
1687 fix: None,
1688 });
1689 }
1690 continue;
1691 }
1692
1693 let full_url = destination.full_url();
1696
1697 if let Some(self_link) = self.self_referential_link(
1702 &full_url,
1703 &base_path,
1704 &extra_search_paths,
1705 self_path.as_deref(),
1706 ctx.link_target_policy(),
1707 ) {
1708 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1709 warnings.push(LintWarning {
1710 rule_name: Some(self.name().to_string()),
1711 line,
1712 column,
1713 end_line: line,
1714 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1715 message: Self::self_referential_message(&full_url, &self_link),
1716 severity: Severity::Warning,
1717 fix: match &self_link {
1718 SelfReferentialLink::Fragment(fragment) => {
1719 Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1720 }
1721 SelfReferentialLink::WholeFile => None,
1722 },
1723 });
1724 continue;
1725 }
1726
1727 if let Some(suggestion) = self.compact_path_suggestion(&full_url, &base_path) {
1728 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1729 warnings.push(LintWarning {
1730 rule_name: Some(self.name().to_string()),
1731 line,
1732 column,
1733 end_line: line,
1734 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1735 message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1736 severity: Severity::Warning,
1737 fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1738 });
1739 }
1740
1741 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1742 continue;
1743 }
1744
1745 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1747 warnings.push(LintWarning {
1748 rule_name: Some(self.name().to_string()),
1749 line,
1750 column,
1751 end_line: line,
1752 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1753 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1754 severity: Severity::Error,
1755 fix: None,
1756 });
1757 }
1758
1759 for image in ctx.images() {
1761 if ctx
1763 .line_info(image.line)
1764 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1765 {
1766 continue;
1767 }
1768
1769 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1773 continue;
1774 }
1775
1776 if ctx.is_in_shortcode(image.byte_offset) {
1779 continue;
1780 }
1781
1782 let url = image.url.as_ref();
1783
1784 if url.is_empty() {
1786 continue;
1787 }
1788
1789 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1791 continue;
1792 }
1793
1794 if Self::is_absolute_path(url) {
1796 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1797 warnings.push(LintWarning {
1798 rule_name: Some(self.name().to_string()),
1799 line: image.line,
1800 column: image.start_col + 1,
1801 end_line: image.line,
1802 end_column: image.start_col + 1 + url.chars().count(),
1803 message,
1804 severity: Severity::Warning,
1805 fix: None,
1806 });
1807 }
1808 continue;
1809 }
1810
1811 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1813 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1816 let fix_byte_start = image.byte_offset + url_offset;
1817 let fix_byte_end = fix_byte_start + url.len();
1818 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1819 });
1820
1821 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1822 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1823 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1826 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1827 });
1828 warnings.push(LintWarning {
1829 rule_name: Some(self.name().to_string()),
1830 line: image.line,
1831 column: url_col,
1832 end_line: image.line,
1833 end_column: url_col + url.chars().count(),
1834 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1835 severity: Severity::Warning,
1836 fix,
1837 });
1838 }
1839
1840 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1841 continue;
1842 }
1843
1844 warnings.push(LintWarning {
1847 rule_name: Some(self.name().to_string()),
1848 line: image.line,
1849 column: image.start_col + 1,
1850 end_line: image.line,
1851 end_column: image.start_col + 1 + url.chars().count(),
1852 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1853 severity: Severity::Error,
1854 fix: None,
1855 });
1856 }
1857
1858 for ref_def in ctx.reference_definitions() {
1860 if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1861 continue;
1862 }
1863 let url = &ref_def.url;
1864
1865 if url.is_empty() {
1867 continue;
1868 }
1869
1870 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1872 continue;
1873 }
1874
1875 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1879 let (line, col) = url_range
1880 .as_ref()
1881 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1882 let end_col = col + url.chars().count();
1883
1884 if Self::is_absolute_path(url) {
1886 if let Some(message) = self.absolute_link_message(url, &base_path, &project_root) {
1887 warnings.push(LintWarning {
1888 rule_name: Some(self.name().to_string()),
1889 line,
1890 column: col,
1891 end_line: line,
1892 end_column: end_col,
1893 message,
1894 severity: Severity::Warning,
1895 fix: None,
1896 });
1897 }
1898 continue;
1899 }
1900
1901 if let Some(self_link) = self.self_referential_link(
1903 url,
1904 &base_path,
1905 &extra_search_paths,
1906 self_path.as_deref(),
1907 ctx.link_target_policy(),
1908 ) {
1909 warnings.push(LintWarning {
1910 rule_name: Some(self.name().to_string()),
1911 line,
1912 column: col,
1913 end_line: line,
1914 end_column: end_col,
1915 message: Self::self_referential_message(url, &self_link),
1916 severity: Severity::Warning,
1917 fix: match (&self_link, &url_range) {
1918 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1919 Some(Fix::new(range.clone(), fragment.clone()))
1920 }
1921 _ => None,
1922 },
1923 });
1924 continue;
1925 }
1926
1927 if let Some(suggestion) = self.compact_path_suggestion(url, &base_path) {
1929 warnings.push(LintWarning {
1930 rule_name: Some(self.name().to_string()),
1931 line,
1932 column: col,
1933 end_line: line,
1934 end_column: end_col,
1935 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1936 severity: Severity::Warning,
1937 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1938 });
1939 }
1940
1941 if Self::relative_target_exists(url, &base_path, &extra_search_paths, ctx.link_target_policy()) {
1942 continue;
1943 }
1944
1945 warnings.push(LintWarning {
1947 rule_name: Some(self.name().to_string()),
1948 line,
1949 column: col,
1950 end_line: line,
1951 end_column: end_col,
1952 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1953 severity: Severity::Error,
1954 fix: None,
1955 });
1956 }
1957
1958 self.check_front_matter(ctx, &base_path, &extra_search_paths, &project_root, &mut warnings);
1959
1960 Ok(warnings)
1961 }
1962
1963 fn fix_capability(&self) -> FixCapability {
1964 if self.produces_fixes() {
1965 FixCapability::ConditionallyFixable
1966 } else {
1967 FixCapability::Unfixable
1968 }
1969 }
1970
1971 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1972 if !self.produces_fixes() {
1973 return Ok(ctx.content.to_string());
1974 }
1975
1976 let warnings = self.check(ctx)?;
1977 let warnings =
1978 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1979 let mut content = ctx.content.to_string();
1980
1981 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1983 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1984
1985 let mut last_applied_start: Option<usize> = None;
1991 for fix in fixes {
1992 if let Some(prev_start) = last_applied_start
1993 && fix.range.end > prev_start
1994 {
1995 continue;
1996 }
1997 if fix.range.end <= content.len() {
1998 content.replace_range(fix.range.clone(), &fix.replacement);
1999 last_applied_start = Some(fix.range.start);
2000 }
2001 }
2002
2003 Ok(content)
2004 }
2005
2006 fn as_any(&self) -> &dyn std::any::Any {
2007 self
2008 }
2009
2010 crate::impl_rule_config_sections!(MD057Config);
2011
2012 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
2013 where
2014 Self: Sized,
2015 {
2016 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
2017 Box::new(Self::from_config_struct(rule_config))
2021 }
2022
2023 fn cross_file_scope(&self) -> CrossFileScope {
2024 CrossFileScope::Workspace
2025 }
2026
2027 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
2028 self.contribute_dependency_targets(ctx, index);
2029
2030 let links = extract_cross_file_links(ctx);
2033 for link in links.relative {
2034 index.add_cross_file_link(link);
2035 }
2036 for link in links.root_relative {
2039 index.add_root_relative_link(link);
2040 }
2041 }
2042
2043 fn cross_file_check(
2044 &self,
2045 _file_path: &Path,
2046 _file_index: &FileIndex,
2047 _workspace_index: &crate::workspace_index::WorkspaceIndex,
2048 ) -> LintResult {
2049 Ok(Vec::new())
2059 }
2060}
2061
2062fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
2067 let from_components: Vec<_> = from_dir.components().collect();
2068 let to_components: Vec<_> = to_path.components().collect();
2069
2070 let common_len = from_components
2072 .iter()
2073 .zip(to_components.iter())
2074 .take_while(|(a, b)| a == b)
2075 .count();
2076
2077 let mut result = PathBuf::new();
2078
2079 for _ in common_len..from_components.len() {
2081 result.push("..");
2082 }
2083
2084 for component in &to_components[common_len..] {
2086 result.push(component);
2087 }
2088
2089 result
2090}
2091
2092fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
2098 let link_path = Path::new(raw_link_path);
2099
2100 let has_traversal = link_path
2102 .components()
2103 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
2104
2105 if !has_traversal {
2106 return None;
2107 }
2108
2109 let combined = source_dir.join(link_path);
2111 let normalized_target = normalize_relative_path(&combined);
2112
2113 let normalized_source = normalize_relative_path(source_dir);
2115 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2116
2117 if shortest != link_path {
2119 let compact = shortest.to_string_lossy().to_string();
2120 if compact.is_empty() {
2122 return None;
2123 }
2124 Some(compact.replace('\\', "/"))
2126 } else {
2127 None
2128 }
2129}
2130
2131#[cfg(test)]
2132mod tests {
2133 use super::*;
2134 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2135 use std::fs::File;
2136 use std::io::Write;
2137 use tempfile::tempdir;
2138
2139 #[test]
2140 fn test_strip_query_and_fragment() {
2141 assert_eq!(
2143 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2144 "file.png"
2145 );
2146 assert_eq!(
2147 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2148 "file.png"
2149 );
2150 assert_eq!(
2151 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2152 "file.png"
2153 );
2154
2155 assert_eq!(
2157 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2158 "file.md"
2159 );
2160 assert_eq!(
2161 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2162 "file.md"
2163 );
2164
2165 assert_eq!(
2167 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2168 "file.md"
2169 );
2170
2171 assert_eq!(
2173 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2174 "file.png"
2175 );
2176
2177 assert_eq!(
2179 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2180 "path/to/image.png"
2181 );
2182 assert_eq!(
2183 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2184 "path/to/image.png"
2185 );
2186
2187 assert_eq!(
2189 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2190 "file.md"
2191 );
2192 }
2193
2194 #[test]
2195 fn test_url_decode() {
2196 assert_eq!(
2198 MD057ExistingRelativeLinks::url_decode("penguin%20with%20space.jpg"),
2199 "penguin with space.jpg"
2200 );
2201
2202 assert_eq!(
2204 MD057ExistingRelativeLinks::url_decode("assets/my%20file%20name.png"),
2205 "assets/my file name.png"
2206 );
2207
2208 assert_eq!(
2210 MD057ExistingRelativeLinks::url_decode("hello%20world%21.md"),
2211 "hello world!.md"
2212 );
2213
2214 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2e%2e"), "/..");
2216
2217 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2F%2E%2E"), "/..");
2219
2220 assert_eq!(MD057ExistingRelativeLinks::url_decode("%2f%2E%2e"), "/..");
2222
2223 assert_eq!(
2225 MD057ExistingRelativeLinks::url_decode("normal-file.md"),
2226 "normal-file.md"
2227 );
2228
2229 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%2.txt"), "file%2.txt");
2231
2232 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%"), "file%");
2234
2235 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%GG.txt"), "file%GG.txt");
2237
2238 assert_eq!(MD057ExistingRelativeLinks::url_decode("file+name.txt"), "file+name.txt");
2240
2241 assert_eq!(MD057ExistingRelativeLinks::url_decode(""), "");
2243
2244 assert_eq!(MD057ExistingRelativeLinks::url_decode("caf%C3%A9.md"), "café.md");
2246
2247 assert_eq!(MD057ExistingRelativeLinks::url_decode("%20%20%20"), " ");
2249
2250 assert_eq!(
2252 MD057ExistingRelativeLinks::url_decode("path%2Fto%2Ffile.md"),
2253 "path/to/file.md"
2254 );
2255
2256 assert_eq!(
2258 MD057ExistingRelativeLinks::url_decode("hello%20world/foo%20bar.md"),
2259 "hello world/foo bar.md"
2260 );
2261
2262 assert_eq!(MD057ExistingRelativeLinks::url_decode("file%5B1%5D.md"), "file[1].md");
2264
2265 assert_eq!(MD057ExistingRelativeLinks::url_decode("100%pure.md"), "100%pure.md");
2267 }
2268
2269 #[test]
2270 fn test_url_encoded_filenames() {
2271 let temp_dir = tempdir().unwrap();
2273 let base_path = temp_dir.path();
2274
2275 let file_with_spaces = base_path.join("penguin with space.jpg");
2277 File::create(&file_with_spaces)
2278 .unwrap()
2279 .write_all(b"image data")
2280 .unwrap();
2281
2282 let subdir = base_path.join("my images");
2284 std::fs::create_dir(&subdir).unwrap();
2285 let nested_file = subdir.join("photo 1.png");
2286 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2287
2288 let content = r#"
2290# Test Document with URL-Encoded Links
2291
2292
2293
2294
2295"#;
2296
2297 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2298
2299 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2300 let result = rule.check(&ctx).unwrap();
2301
2302 assert_eq!(
2304 result.len(),
2305 1,
2306 "Should only warn about missing%20file.jpg. Got: {result:?}"
2307 );
2308 assert!(
2309 result[0].message.contains("missing%20file.jpg"),
2310 "Warning should mention the URL-encoded filename"
2311 );
2312 }
2313
2314 #[test]
2315 fn test_external_urls() {
2316 let rule = MD057ExistingRelativeLinks::new();
2317
2318 assert!(rule.is_external_url("https://example.com"));
2320 assert!(rule.is_external_url("http://example.com"));
2321 assert!(rule.is_external_url("ftp://example.com"));
2322 assert!(rule.is_external_url("www.example.com"));
2323 assert!(rule.is_external_url("example.com"));
2324
2325 assert!(rule.is_external_url("file:///path/to/file"));
2327 assert!(rule.is_external_url("smb://server/share"));
2328 assert!(rule.is_external_url("macappstores://apps.apple.com/"));
2329 assert!(rule.is_external_url("mailto:user@example.com"));
2330 assert!(rule.is_external_url("tel:+1234567890"));
2331 assert!(rule.is_external_url("data:text/plain;base64,SGVsbG8="));
2332 assert!(rule.is_external_url("javascript:void(0)"));
2333 assert!(rule.is_external_url("ssh://git@github.com/repo"));
2334 assert!(rule.is_external_url("git://github.com/repo.git"));
2335
2336 assert!(rule.is_external_url("user@example.com"));
2339 assert!(rule.is_external_url("steering@kubernetes.io"));
2340 assert!(rule.is_external_url("john.doe+filter@company.co.uk"));
2341 assert!(rule.is_external_url("user_name@sub.domain.com"));
2342 assert!(rule.is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2343
2344 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"));
2355 assert!(!rule.is_external_url("/blog/2024/release.html"));
2356 assert!(!rule.is_external_url("/react/hooks/use-state.html"));
2357 assert!(!rule.is_external_url("/pkg/runtime"));
2358 assert!(!rule.is_external_url("/doc/go1compat"));
2359 assert!(!rule.is_external_url("/index.html"));
2360 assert!(!rule.is_external_url("/assets/logo.png"));
2361
2362 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2364 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2365 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2366 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2367 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2368
2369 assert!(rule.is_external_url("~/assets/image.png"));
2372 assert!(rule.is_external_url("~/components/Button.vue"));
2373 assert!(rule.is_external_url("~assets/logo.svg")); assert!(rule.is_external_url("@/components/Header.vue"));
2377 assert!(rule.is_external_url("@images/photo.jpg"));
2378 assert!(rule.is_external_url("@assets/styles.css"));
2379
2380 assert!(!rule.is_external_url("./relative/path.md"));
2382 assert!(!rule.is_external_url("relative/path.md"));
2383 assert!(!rule.is_external_url("../parent/path.md"));
2384 }
2385
2386 #[test]
2387 fn test_dot_com_only_skips_bare_domains() {
2388 let rule = MD057ExistingRelativeLinks::new();
2389
2390 assert!(rule.is_external_url("example.com"));
2392 assert!(rule.is_external_url("sub.example.com"));
2393
2394 assert!(!rule.is_external_url("../../vendor.com"));
2398 assert!(!rule.is_external_url("./vendor.com"));
2399 assert!(!rule.is_external_url("docs/vendor.com"));
2400 }
2401
2402 #[test]
2403 fn test_framework_path_aliases() {
2404 let temp_dir = tempdir().unwrap();
2406 let base_path = temp_dir.path();
2407
2408 let content = r#"
2410# Framework Path Aliases
2411
2412
2413
2414
2415
2416[Link](@/pages/about.md)
2417
2418This is a [real missing link](missing.md) that should be flagged.
2419"#;
2420
2421 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2422
2423 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2424 let result = rule.check(&ctx).unwrap();
2425
2426 assert_eq!(
2428 result.len(),
2429 1,
2430 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2431 );
2432 assert!(
2433 result[0].message.contains("missing.md"),
2434 "Warning should be for missing.md"
2435 );
2436 }
2437
2438 #[test]
2439 fn test_url_decode_security_path_traversal() {
2440 let temp_dir = tempdir().unwrap();
2443 let base_path = temp_dir.path();
2444
2445 let file_in_base = base_path.join("safe.md");
2447 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2448
2449 let content = r#"
2454[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2455[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2456[Safe link](safe.md)
2457"#;
2458
2459 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2460
2461 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2462 let result = rule.check(&ctx).unwrap();
2463
2464 assert_eq!(
2467 result.len(),
2468 2,
2469 "Should have warnings for traversal attempts. Got: {result:?}"
2470 );
2471 }
2472
2473 #[test]
2474 fn test_url_encoded_utf8_filenames() {
2475 let temp_dir = tempdir().unwrap();
2477 let base_path = temp_dir.path();
2478
2479 let cafe_file = base_path.join("café.md");
2481 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2482
2483 let content = r#"
2484[Café link](caf%C3%A9.md)
2485[Missing unicode](r%C3%A9sum%C3%A9.md)
2486"#;
2487
2488 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2489
2490 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2491 let result = rule.check(&ctx).unwrap();
2492
2493 assert_eq!(
2495 result.len(),
2496 1,
2497 "Should only warn about missing résumé.md. Got: {result:?}"
2498 );
2499 assert!(
2500 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2501 "Warning should mention the URL-encoded filename"
2502 );
2503 }
2504
2505 #[test]
2506 fn test_url_encoded_emoji_filenames() {
2507 let temp_dir = tempdir().unwrap();
2510 let base_path = temp_dir.path();
2511
2512 let emoji_dir = base_path.join("👤 Personal");
2514 std::fs::create_dir(&emoji_dir).unwrap();
2515
2516 let file_path = emoji_dir.join("TV Shows.md");
2518 File::create(&file_path)
2519 .unwrap()
2520 .write_all(b"# TV Shows\n\nContent here.")
2521 .unwrap();
2522
2523 let content = r#"
2526# Test Document
2527
2528[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2529[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2530"#;
2531
2532 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2533
2534 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2535 let result = rule.check(&ctx).unwrap();
2536
2537 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2539 assert!(
2540 result[0].message.contains("Missing.md"),
2541 "Warning should be for Missing.md, got: {}",
2542 result[0].message
2543 );
2544 }
2545
2546 #[test]
2547 fn test_no_warnings_without_base_path() {
2548 let rule = MD057ExistingRelativeLinks::new();
2549 let content = "[Link](missing.md)";
2550
2551 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2552 let result = rule.check(&ctx).unwrap();
2553 assert!(result.is_empty(), "Should have no warnings without base path");
2554 }
2555
2556 #[test]
2557 fn test_existing_and_missing_links() {
2558 let temp_dir = tempdir().unwrap();
2560 let base_path = temp_dir.path();
2561
2562 let exists_path = base_path.join("exists.md");
2564 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2565
2566 assert!(exists_path.exists(), "exists.md should exist for this test");
2568
2569 let content = r#"
2571# Test Document
2572
2573[Valid Link](exists.md)
2574[Invalid Link](missing.md)
2575[External Link](https://example.com)
2576[Media Link](image.jpg)
2577 "#;
2578
2579 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2581
2582 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2584 let result = rule.check(&ctx).unwrap();
2585
2586 assert_eq!(result.len(), 2);
2588 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2589 assert!(messages.iter().any(|m| m.contains("missing.md")));
2590 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2591 }
2592
2593 #[test]
2594 fn test_angle_bracket_links() {
2595 let temp_dir = tempdir().unwrap();
2597 let base_path = temp_dir.path();
2598
2599 let exists_path = base_path.join("exists.md");
2601 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2602
2603 let content = r#"
2605# Test Document
2606
2607[Valid Link](<exists.md>)
2608[Invalid Link](<missing.md>)
2609[External Link](<https://example.com>)
2610 "#;
2611
2612 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2614
2615 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2616 let result = rule.check(&ctx).unwrap();
2617
2618 assert_eq!(result.len(), 1, "Should have exactly one warning");
2620 assert!(
2621 result[0].message.contains("missing.md"),
2622 "Warning should mention missing.md"
2623 );
2624 }
2625
2626 #[test]
2627 fn test_angle_bracket_links_with_parens() {
2628 let temp_dir = tempdir().unwrap();
2630 let base_path = temp_dir.path();
2631
2632 let app_dir = base_path.join("app");
2634 std::fs::create_dir(&app_dir).unwrap();
2635 let upload_dir = app_dir.join("(upload)");
2636 std::fs::create_dir(&upload_dir).unwrap();
2637 let page_file = upload_dir.join("page.tsx");
2638 File::create(&page_file)
2639 .unwrap()
2640 .write_all(b"export default function Page() {}")
2641 .unwrap();
2642
2643 let content = r#"
2645# Test Document with Paths Containing Parens
2646
2647[Upload Page](<app/(upload)/page.tsx>)
2648[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2649[Missing](<app/(missing)/file.md>)
2650"#;
2651
2652 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2653
2654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2655 let result = rule.check(&ctx).unwrap();
2656
2657 assert_eq!(
2659 result.len(),
2660 1,
2661 "Should have exactly one warning for missing file. Got: {result:?}"
2662 );
2663 assert!(
2664 result[0].message.contains("app/(missing)/file.md"),
2665 "Warning should mention app/(missing)/file.md"
2666 );
2667 }
2668
2669 #[test]
2670 fn test_balanced_parentheses_in_link_paths() {
2671 let temp_dir = tempdir().unwrap();
2676 let base_path = temp_dir.path();
2677
2678 let paren_file = base_path.join("file(inner).md");
2680 File::create(&paren_file)
2681 .unwrap()
2682 .write_all(b"# file(inner).md\n")
2683 .unwrap();
2684
2685 let folder = base_path.join("folder(inner)");
2687 std::fs::create_dir(&folder).unwrap();
2688 File::create(folder.join("file.md"))
2689 .unwrap()
2690 .write_all(b"# folder(inner)/file.md\n")
2691 .unwrap();
2692
2693 let content = r#"
2694# Test cases
2695
2696[File with parenthesis exists](file(inner).md)
2697[Folder with parenthesis exists](folder(inner)/file.md)
2698[Missing with parenthesis](missing(inner).md)
2699"#;
2700
2701 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2702
2703 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2704 let result = rule.check(&ctx).unwrap();
2705
2706 assert_eq!(
2709 result.len(),
2710 1,
2711 "Expected exactly one warning (the missing file). Got: {result:?}"
2712 );
2713 assert!(
2714 result[0].message.contains("missing(inner).md"),
2715 "Warning should name the full path `missing(inner).md`, got: {}",
2716 result[0].message
2717 );
2718 }
2719
2720 #[test]
2721 fn test_all_file_types_checked() {
2722 let temp_dir = tempdir().unwrap();
2724 let base_path = temp_dir.path();
2725
2726 let content = r#"
2728[Image Link](image.jpg)
2729[Video Link](video.mp4)
2730[Markdown Link](document.md)
2731[PDF Link](file.pdf)
2732"#;
2733
2734 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2735
2736 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2737 let result = rule.check(&ctx).unwrap();
2738
2739 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2741 }
2742
2743 #[test]
2744 fn test_code_span_detection() {
2745 let rule = MD057ExistingRelativeLinks::new();
2746
2747 let temp_dir = tempdir().unwrap();
2749 let base_path = temp_dir.path();
2750
2751 let rule = rule.with_path(base_path);
2752
2753 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2755
2756 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2757 let result = rule.check(&ctx).unwrap();
2758
2759 assert_eq!(result.len(), 1, "Should only flag the real link");
2761 assert!(result[0].message.contains("nonexistent.md"));
2762 }
2763
2764 #[test]
2765 fn test_inline_code_spans() {
2766 let temp_dir = tempdir().unwrap();
2768 let base_path = temp_dir.path();
2769
2770 let content = r#"
2772# Test Document
2773
2774This is a normal link: [Link](missing.md)
2775
2776This is a code span with a link: `[Link](another-missing.md)`
2777
2778Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2779
2780 "#;
2781
2782 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2784
2785 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2787 let result = rule.check(&ctx).unwrap();
2788
2789 assert_eq!(result.len(), 1, "Should have exactly one warning");
2791 assert!(
2792 result[0].message.contains("missing.md"),
2793 "Warning should be for missing.md"
2794 );
2795 assert!(
2796 !result.iter().any(|w| w.message.contains("another-missing.md")),
2797 "Should not warn about link in code span"
2798 );
2799 assert!(
2800 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2801 "Should not warn about link in inline code"
2802 );
2803 }
2804
2805 #[test]
2806 fn test_extensionless_link_resolution() {
2807 let temp_dir = tempdir().unwrap();
2809 let base_path = temp_dir.path();
2810
2811 let page_path = base_path.join("page.md");
2813 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2814
2815 let content = r#"
2817# Test Document
2818
2819[Link without extension](page)
2820[Link with extension](page.md)
2821[Missing link](nonexistent)
2822"#;
2823
2824 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2825
2826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2827 let result = rule.check(&ctx).unwrap();
2828
2829 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2832 assert!(
2833 result[0].message.contains("nonexistent"),
2834 "Warning should be for 'nonexistent' not 'page'"
2835 );
2836 }
2837
2838 #[test]
2840 fn test_cross_file_scope() {
2841 let rule = MD057ExistingRelativeLinks::new();
2842 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2843 }
2844
2845 #[test]
2846 fn test_contribute_to_index_extracts_markdown_links() {
2847 let rule = MD057ExistingRelativeLinks::new();
2848 let content = r#"
2849# Document
2850
2851[Link to docs](./docs/guide.md)
2852[Link with fragment](./other.md#section)
2853[External link](https://example.com)
2854[Image link](image.png)
2855[Media file](video.mp4)
2856"#;
2857
2858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2859 let mut index = FileIndex::new();
2860 rule.contribute_to_index(&ctx, &mut index);
2861
2862 assert_eq!(index.cross_file_links.len(), 2);
2864
2865 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2867 assert_eq!(index.cross_file_links[0].fragment, "");
2868
2869 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2871 assert_eq!(index.cross_file_links[1].fragment, "section");
2872 }
2873
2874 #[test]
2875 fn test_contribute_to_index_skips_external_and_anchors() {
2876 let rule = MD057ExistingRelativeLinks::new();
2877 let content = r#"
2878# Document
2879
2880[External](https://example.com)
2881[Another external](http://example.org)
2882[Fragment only](#section)
2883[FTP link](ftp://files.example.com)
2884[Mail link](mailto:test@example.com)
2885[WWW link](www.example.com)
2886"#;
2887
2888 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2889 let mut index = FileIndex::new();
2890 rule.contribute_to_index(&ctx, &mut index);
2891
2892 assert_eq!(index.cross_file_links.len(), 0);
2894 }
2895
2896 #[test]
2897 fn test_cross_file_check_valid_link() {
2898 use crate::workspace_index::WorkspaceIndex;
2899
2900 let rule = MD057ExistingRelativeLinks::new();
2901
2902 let mut workspace_index = WorkspaceIndex::new();
2904 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2905
2906 let mut file_index = FileIndex::new();
2908 file_index.add_cross_file_link(CrossFileLinkIndex {
2909 target_path: "guide.md".to_string(),
2910 fragment: "".to_string(),
2911 line: 5,
2912 column: 1,
2913 origin: LinkOrigin::Body,
2914 });
2915
2916 let warnings = rule
2918 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2919 .unwrap();
2920
2921 assert!(warnings.is_empty());
2923 }
2924
2925 #[test]
2926 fn test_cross_file_check_missing_link() {
2927 use crate::workspace_index::WorkspaceIndex;
2930
2931 let rule = MD057ExistingRelativeLinks::new();
2932 let workspace_index = WorkspaceIndex::new();
2933
2934 let mut file_index = FileIndex::new();
2935 file_index.add_cross_file_link(CrossFileLinkIndex {
2936 target_path: "missing.md".to_string(),
2937 fragment: "".to_string(),
2938 line: 5,
2939 column: 1,
2940 origin: LinkOrigin::Body,
2941 });
2942
2943 let warnings = rule
2944 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2945 .unwrap();
2946
2947 assert!(
2949 warnings.is_empty(),
2950 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2951 );
2952 }
2953
2954 #[test]
2955 fn test_cross_file_check_parent_path() {
2956 use crate::workspace_index::WorkspaceIndex;
2957
2958 let rule = MD057ExistingRelativeLinks::new();
2959
2960 let mut workspace_index = WorkspaceIndex::new();
2962 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2963
2964 let mut file_index = FileIndex::new();
2966 file_index.add_cross_file_link(CrossFileLinkIndex {
2967 target_path: "../readme.md".to_string(),
2968 fragment: "".to_string(),
2969 line: 5,
2970 column: 1,
2971 origin: LinkOrigin::Body,
2972 });
2973
2974 let warnings = rule
2976 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2977 .unwrap();
2978
2979 assert!(warnings.is_empty());
2981 }
2982
2983 #[test]
2984 fn test_cross_file_check_html_link_with_md_source() {
2985 use crate::workspace_index::WorkspaceIndex;
2988
2989 let rule = MD057ExistingRelativeLinks::new();
2990
2991 let mut workspace_index = WorkspaceIndex::new();
2993 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2994
2995 let mut file_index = FileIndex::new();
2997 file_index.add_cross_file_link(CrossFileLinkIndex {
2998 target_path: "guide.html".to_string(),
2999 fragment: "section".to_string(),
3000 line: 10,
3001 column: 5,
3002 origin: LinkOrigin::Body,
3003 });
3004
3005 let warnings = rule
3007 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3008 .unwrap();
3009
3010 assert!(
3012 warnings.is_empty(),
3013 "Expected no warnings for .html link with .md source, got: {warnings:?}"
3014 );
3015 }
3016
3017 #[test]
3018 fn test_cross_file_check_html_link_without_source() {
3019 use crate::workspace_index::WorkspaceIndex;
3023
3024 let rule = MD057ExistingRelativeLinks::new();
3025 let workspace_index = WorkspaceIndex::new();
3026
3027 let mut file_index = FileIndex::new();
3028 file_index.add_cross_file_link(CrossFileLinkIndex {
3029 target_path: "missing.html".to_string(),
3030 fragment: "".to_string(),
3031 line: 10,
3032 column: 5,
3033 origin: LinkOrigin::Body,
3034 });
3035
3036 let warnings = rule
3037 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3038 .unwrap();
3039
3040 assert!(
3042 warnings.is_empty(),
3043 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
3044 );
3045 }
3046
3047 #[test]
3048 fn test_normalize_path_function() {
3049 assert_eq!(
3051 normalize_relative_path(Path::new("docs/guide.md")),
3052 PathBuf::from("docs/guide.md")
3053 );
3054
3055 assert_eq!(
3057 normalize_relative_path(Path::new("./docs/guide.md")),
3058 PathBuf::from("docs/guide.md")
3059 );
3060
3061 assert_eq!(
3063 normalize_relative_path(Path::new("docs/sub/../guide.md")),
3064 PathBuf::from("docs/guide.md")
3065 );
3066
3067 assert_eq!(
3069 normalize_relative_path(Path::new("a/b/c/../../d.md")),
3070 PathBuf::from("a/d.md")
3071 );
3072 }
3073
3074 #[test]
3075 fn test_html_link_with_md_source() {
3076 let temp_dir = tempdir().unwrap();
3078 let base_path = temp_dir.path();
3079
3080 let md_file = base_path.join("guide.md");
3082 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3083
3084 let content = r#"
3085[Read the guide](guide.html)
3086[Also here](getting-started.html)
3087"#;
3088
3089 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3090 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3091 let result = rule.check(&ctx).unwrap();
3092
3093 assert_eq!(
3095 result.len(),
3096 1,
3097 "Should only warn about missing source. Got: {result:?}"
3098 );
3099 assert!(result[0].message.contains("getting-started.html"));
3100 }
3101
3102 #[test]
3103 fn test_htm_link_with_md_source() {
3104 let temp_dir = tempdir().unwrap();
3106 let base_path = temp_dir.path();
3107
3108 let md_file = base_path.join("page.md");
3109 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
3110
3111 let content = "[Page](page.htm)";
3112
3113 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3114 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3115 let result = rule.check(&ctx).unwrap();
3116
3117 assert!(
3118 result.is_empty(),
3119 "Should not warn when .md source exists for .htm link"
3120 );
3121 }
3122
3123 #[test]
3124 fn test_html_link_finds_various_markdown_extensions() {
3125 let temp_dir = tempdir().unwrap();
3127 let base_path = temp_dir.path();
3128
3129 File::create(base_path.join("doc.md")).unwrap();
3130 File::create(base_path.join("tutorial.mdx")).unwrap();
3131 File::create(base_path.join("guide.markdown")).unwrap();
3132
3133 let content = r#"
3134[Doc](doc.html)
3135[Tutorial](tutorial.html)
3136[Guide](guide.html)
3137"#;
3138
3139 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3140 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3141 let result = rule.check(&ctx).unwrap();
3142
3143 assert!(
3144 result.is_empty(),
3145 "Should find all markdown variants as source files. Got: {result:?}"
3146 );
3147 }
3148
3149 #[test]
3150 fn test_html_link_in_subdirectory() {
3151 let temp_dir = tempdir().unwrap();
3153 let base_path = temp_dir.path();
3154
3155 let docs_dir = base_path.join("docs");
3156 std::fs::create_dir(&docs_dir).unwrap();
3157 File::create(docs_dir.join("guide.md"))
3158 .unwrap()
3159 .write_all(b"# Guide")
3160 .unwrap();
3161
3162 let content = "[Guide](docs/guide.html)";
3163
3164 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3165 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3166 let result = rule.check(&ctx).unwrap();
3167
3168 assert!(result.is_empty(), "Should find markdown source in subdirectory");
3169 }
3170
3171 #[test]
3172 fn test_absolute_path_skipped_in_check() {
3173 let temp_dir = tempdir().unwrap();
3176 let base_path = temp_dir.path();
3177
3178 let content = r#"
3179# Test Document
3180
3181[Go Runtime](/pkg/runtime)
3182[Go Runtime with Fragment](/pkg/runtime#section)
3183[API Docs](/api/v1/users)
3184[Blog Post](/blog/2024/release.html)
3185[React Hook](/react/hooks/use-state.html)
3186"#;
3187
3188 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3189 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3190 let result = rule.check(&ctx).unwrap();
3191
3192 assert!(
3194 result.is_empty(),
3195 "Absolute paths should be skipped. Got warnings: {result:?}"
3196 );
3197 }
3198
3199 #[test]
3200 fn test_absolute_path_skipped_in_cross_file_check() {
3201 use crate::workspace_index::WorkspaceIndex;
3203
3204 let rule = MD057ExistingRelativeLinks::new();
3205
3206 let workspace_index = WorkspaceIndex::new();
3208
3209 let mut file_index = FileIndex::new();
3211 file_index.add_cross_file_link(CrossFileLinkIndex {
3212 target_path: "/pkg/runtime.md".to_string(),
3213 fragment: "".to_string(),
3214 line: 5,
3215 column: 1,
3216 origin: LinkOrigin::Body,
3217 });
3218 file_index.add_cross_file_link(CrossFileLinkIndex {
3219 target_path: "/api/v1/users.md".to_string(),
3220 fragment: "section".to_string(),
3221 line: 10,
3222 column: 1,
3223 origin: LinkOrigin::Body,
3224 });
3225
3226 let warnings = rule
3228 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3229 .unwrap();
3230
3231 assert!(
3233 warnings.is_empty(),
3234 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3235 );
3236 }
3237
3238 #[test]
3239 fn test_protocol_relative_url_not_skipped() {
3240 let temp_dir = tempdir().unwrap();
3243 let base_path = temp_dir.path();
3244
3245 let content = r#"
3246# Test Document
3247
3248[External](//example.com/page)
3249[Another](//cdn.example.com/asset.js)
3250"#;
3251
3252 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3253 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3254 let result = rule.check(&ctx).unwrap();
3255
3256 assert!(
3258 result.is_empty(),
3259 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3260 );
3261 }
3262
3263 #[test]
3264 fn test_email_addresses_skipped() {
3265 let temp_dir = tempdir().unwrap();
3268 let base_path = temp_dir.path();
3269
3270 let content = r#"
3271# Test Document
3272
3273[Contact](user@example.com)
3274[Steering](steering@kubernetes.io)
3275[Support](john.doe+filter@company.co.uk)
3276[User](user_name@sub.domain.com)
3277"#;
3278
3279 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3280 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3281 let result = rule.check(&ctx).unwrap();
3282
3283 assert!(
3285 result.is_empty(),
3286 "Email addresses should be skipped. Got warnings: {result:?}"
3287 );
3288 }
3289
3290 #[test]
3291 fn test_email_addresses_vs_file_paths() {
3292 let temp_dir = tempdir().unwrap();
3295 let base_path = temp_dir.path();
3296
3297 let content = r#"
3298# Test Document
3299
3300[Email](user@example.com) <!-- Should be skipped (email) -->
3301[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3302[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3303"#;
3304
3305 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3306 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3307 let result = rule.check(&ctx).unwrap();
3308
3309 assert!(
3311 result.is_empty(),
3312 "All email addresses should be skipped. Got: {result:?}"
3313 );
3314 }
3315
3316 #[test]
3317 fn test_diagnostic_position_accuracy() {
3318 let temp_dir = tempdir().unwrap();
3320 let base_path = temp_dir.path();
3321
3322 let content = "prefix [text](missing.md) suffix";
3325 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3329 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3330 let result = rule.check(&ctx).unwrap();
3331
3332 assert_eq!(result.len(), 1, "Should have exactly one warning");
3333 assert_eq!(result[0].line, 1, "Should be on line 1");
3334 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3335 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3336 }
3337
3338 #[test]
3339 fn test_diagnostic_position_non_ascii_link() {
3340 let temp_dir = tempdir().unwrap();
3343 let base_path = temp_dir.path();
3344
3345 let content = "ä½ å¥½ä½ å¥½[ä½ å¥½](not-exist.md) bar";
3349
3350 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3351 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3352 let result = rule.check(&ctx).unwrap();
3353
3354 assert_eq!(result.len(), 1, "Should have exactly one warning");
3355 assert_eq!(result[0].line, 1, "Should be on line 1");
3356 assert_eq!(
3357 result[0].column, 10,
3358 "Column must be a character offset, not a byte offset"
3359 );
3360 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3361 }
3362
3363 #[test]
3364 fn test_diagnostic_position_angle_brackets() {
3365 let temp_dir = tempdir().unwrap();
3367 let base_path = temp_dir.path();
3368
3369 let content = "[link](<missing.md>)";
3372 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3375 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3376 let result = rule.check(&ctx).unwrap();
3377
3378 assert_eq!(result.len(), 1, "Should have exactly one warning");
3379 assert_eq!(result[0].line, 1, "Should be on line 1");
3380 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3381 }
3382
3383 #[test]
3384 fn test_diagnostic_position_multiline() {
3385 let temp_dir = tempdir().unwrap();
3387 let base_path = temp_dir.path();
3388
3389 let content = r#"# Title
3390Some text on line 2
3391[link on line 3](missing1.md)
3392More text
3393[link on line 5](missing2.md)"#;
3394
3395 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3396 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3397 let result = rule.check(&ctx).unwrap();
3398
3399 assert_eq!(result.len(), 2, "Should have two warnings");
3400
3401 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3403 assert!(result[0].message.contains("missing1.md"));
3404
3405 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3407 assert!(result[1].message.contains("missing2.md"));
3408 }
3409
3410 #[test]
3411 fn test_diagnostic_position_with_spaces() {
3412 let temp_dir = tempdir().unwrap();
3414 let base_path = temp_dir.path();
3415
3416 let content = "[link]( missing.md )";
3417 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3422 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3423 let result = rule.check(&ctx).unwrap();
3424
3425 assert_eq!(result.len(), 1, "Should have exactly one warning");
3426 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3428 }
3429
3430 #[test]
3431 fn test_diagnostic_position_image() {
3432 let temp_dir = tempdir().unwrap();
3434 let base_path = temp_dir.path();
3435
3436 let content = "";
3437
3438 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3439 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3440 let result = rule.check(&ctx).unwrap();
3441
3442 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3443 assert_eq!(result[0].line, 1);
3444 assert!(result[0].column > 0, "Should have valid column position");
3446 assert!(result[0].message.contains("missing.jpg"));
3447 }
3448
3449 #[test]
3450 fn test_diagnostic_position_non_ascii_image() {
3451 let temp_dir = tempdir().unwrap();
3453 let base_path = temp_dir.path();
3454
3455 let content = "ä½ å¥½ä½ å¥½";
3458
3459 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3460 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3461 let result = rule.check(&ctx).unwrap();
3462
3463 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3464 assert_eq!(result[0].line, 1, "Should be on line 1");
3465 assert_eq!(
3466 result[0].column, 5,
3467 "Column must be a character offset, not a byte offset"
3468 );
3469 assert!(result[0].message.contains("not-exist.png"));
3470 }
3471
3472 #[test]
3473 fn test_diagnostic_position_non_ascii_reference_def() {
3474 let temp_dir = tempdir().unwrap();
3478 let base_path = temp_dir.path();
3479
3480 let content = "[ä½ å¥½]: not-exist.md";
3483
3484 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3485 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3486 let result = rule.check(&ctx).unwrap();
3487
3488 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3489 assert_eq!(result[0].line, 1, "Should be on line 1");
3490 assert_eq!(
3491 result[0].column, 7,
3492 "Column must be a character offset, not a byte offset"
3493 );
3494 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3495 }
3496
3497 #[test]
3498 fn test_wikilinks_skipped() {
3499 let temp_dir = tempdir().unwrap();
3502 let base_path = temp_dir.path();
3503
3504 let content = r#"# Test Document
3505
3506[[Microsoft#Windows OS]]
3507[[SomePage]]
3508[[Page With Spaces]]
3509[[path/to/page#section]]
3510[[page|Display Text]]
3511
3512This is a [real missing link](missing.md) that should be flagged.
3513"#;
3514
3515 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3516 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3517 let result = rule.check(&ctx).unwrap();
3518
3519 assert_eq!(
3521 result.len(),
3522 1,
3523 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3524 );
3525 assert!(
3526 result[0].message.contains("missing.md"),
3527 "Warning should be for missing.md, not wikilinks"
3528 );
3529 }
3530
3531 #[test]
3532 fn test_wiki_embeds_skipped() {
3533 let temp_dir = tempdir().unwrap();
3537 let base_path = temp_dir.path();
3538
3539 let content = r#"# Test Document
3540
3541![[diagram.png]]
3542![[subfolder/diagram.png]]
3543![[diagram.png|300]]
3544![[Some Note]]
3545
3546This is a [real missing link](missing.md) that should be flagged.
3547"#;
3548
3549 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3550 for flavor in [
3551 crate::config::MarkdownFlavor::Obsidian,
3552 crate::config::MarkdownFlavor::Standard,
3553 ] {
3554 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3555 let result = rule.check(&ctx).unwrap();
3556
3557 assert_eq!(
3558 result.len(),
3559 1,
3560 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3561 );
3562 assert!(result[0].message.contains("missing.md"));
3563 }
3564 }
3565
3566 #[test]
3567 fn test_wikilinks_not_added_to_index() {
3568 let temp_dir = tempdir().unwrap();
3570 let base_path = temp_dir.path();
3571
3572 let content = r#"# Test Document
3573
3574[[Microsoft#Windows OS]]
3575[[SomePage#section]]
3576[Regular Link](other.md)
3577"#;
3578
3579 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3580 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3581
3582 let mut file_index = FileIndex::new();
3583 rule.contribute_to_index(&ctx, &mut file_index);
3584
3585 let cross_file_links = &file_index.cross_file_links;
3588 assert_eq!(
3589 cross_file_links.len(),
3590 1,
3591 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3592 );
3593 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3594 }
3595
3596 #[test]
3597 fn test_reference_definition_missing_file() {
3598 let temp_dir = tempdir().unwrap();
3600 let base_path = temp_dir.path();
3601
3602 let content = r#"# Test Document
3603
3604[test]: ./missing.md
3605[example]: ./nonexistent.html
3606
3607Use [test] and [example] here.
3608"#;
3609
3610 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3611 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3612 let result = rule.check(&ctx).unwrap();
3613
3614 assert_eq!(
3616 result.len(),
3617 2,
3618 "Should have warnings for missing reference definition targets. Got: {result:?}"
3619 );
3620 assert!(
3621 result.iter().any(|w| w.message.contains("missing.md")),
3622 "Should warn about missing.md"
3623 );
3624 assert!(
3625 result.iter().any(|w| w.message.contains("nonexistent.html")),
3626 "Should warn about nonexistent.html"
3627 );
3628 }
3629
3630 #[test]
3631 fn test_reference_definition_existing_file() {
3632 let temp_dir = tempdir().unwrap();
3634 let base_path = temp_dir.path();
3635
3636 let exists_path = base_path.join("exists.md");
3638 File::create(&exists_path)
3639 .unwrap()
3640 .write_all(b"# Existing file")
3641 .unwrap();
3642
3643 let content = r#"# Test Document
3644
3645[test]: ./exists.md
3646
3647Use [test] here.
3648"#;
3649
3650 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3651 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3652 let result = rule.check(&ctx).unwrap();
3653
3654 assert!(
3656 result.is_empty(),
3657 "Should not warn about existing file. Got: {result:?}"
3658 );
3659 }
3660
3661 #[test]
3662 fn test_reference_definition_external_url_skipped() {
3663 let temp_dir = tempdir().unwrap();
3665 let base_path = temp_dir.path();
3666
3667 let content = r#"# Test Document
3668
3669[google]: https://google.com
3670[example]: http://example.org
3671[mail]: mailto:test@example.com
3672[ftp]: ftp://files.example.com
3673[local]: ./missing.md
3674
3675Use [google], [example], [mail], [ftp], [local] here.
3676"#;
3677
3678 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3679 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3680 let result = rule.check(&ctx).unwrap();
3681
3682 assert_eq!(
3684 result.len(),
3685 1,
3686 "Should only warn about local missing file. Got: {result:?}"
3687 );
3688 assert!(
3689 result[0].message.contains("missing.md"),
3690 "Warning should be for missing.md"
3691 );
3692 }
3693
3694 #[test]
3695 fn test_reference_definition_fragment_only_skipped() {
3696 let temp_dir = tempdir().unwrap();
3698 let base_path = temp_dir.path();
3699
3700 let content = r#"# Test Document
3701
3702[section]: #my-section
3703
3704Use [section] here.
3705"#;
3706
3707 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3708 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3709 let result = rule.check(&ctx).unwrap();
3710
3711 assert!(
3713 result.is_empty(),
3714 "Should not warn about fragment-only reference. Got: {result:?}"
3715 );
3716 }
3717
3718 #[test]
3719 fn test_reference_definition_column_position() {
3720 let temp_dir = tempdir().unwrap();
3722 let base_path = temp_dir.path();
3723
3724 let content = "[ref]: ./missing.md";
3727 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3731 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3732 let result = rule.check(&ctx).unwrap();
3733
3734 assert_eq!(result.len(), 1, "Should have exactly one warning");
3735 assert_eq!(result[0].line, 1, "Should be on line 1");
3736 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3737 }
3738
3739 #[test]
3740 fn test_reference_definition_html_with_md_source() {
3741 let temp_dir = tempdir().unwrap();
3743 let base_path = temp_dir.path();
3744
3745 let md_file = base_path.join("guide.md");
3747 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3748
3749 let content = r#"# Test Document
3750
3751[guide]: ./guide.html
3752[missing]: ./missing.html
3753
3754Use [guide] and [missing] here.
3755"#;
3756
3757 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3758 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3759 let result = rule.check(&ctx).unwrap();
3760
3761 assert_eq!(
3763 result.len(),
3764 1,
3765 "Should only warn about missing source. Got: {result:?}"
3766 );
3767 assert!(result[0].message.contains("missing.html"));
3768 }
3769
3770 #[test]
3771 fn test_reference_definition_url_encoded() {
3772 let temp_dir = tempdir().unwrap();
3774 let base_path = temp_dir.path();
3775
3776 let file_with_spaces = base_path.join("file with spaces.md");
3778 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3779
3780 let content = r#"# Test Document
3781
3782[spaces]: ./file%20with%20spaces.md
3783[missing]: ./missing%20file.md
3784
3785Use [spaces] and [missing] here.
3786"#;
3787
3788 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3789 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3790 let result = rule.check(&ctx).unwrap();
3791
3792 assert_eq!(
3794 result.len(),
3795 1,
3796 "Should only warn about missing URL-encoded file. Got: {result:?}"
3797 );
3798 assert!(result[0].message.contains("missing%20file.md"));
3799 }
3800
3801 #[test]
3802 fn test_inline_and_reference_both_checked() {
3803 let temp_dir = tempdir().unwrap();
3805 let base_path = temp_dir.path();
3806
3807 let content = r#"# Test Document
3808
3809[inline link](./inline-missing.md)
3810[ref]: ./ref-missing.md
3811
3812Use [ref] here.
3813"#;
3814
3815 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3816 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3817 let result = rule.check(&ctx).unwrap();
3818
3819 assert_eq!(
3821 result.len(),
3822 2,
3823 "Should warn about both inline and reference links. Got: {result:?}"
3824 );
3825 assert!(
3826 result.iter().any(|w| w.message.contains("inline-missing.md")),
3827 "Should warn about inline-missing.md"
3828 );
3829 assert!(
3830 result.iter().any(|w| w.message.contains("ref-missing.md")),
3831 "Should warn about ref-missing.md"
3832 );
3833 }
3834
3835 #[test]
3836 fn test_footnote_definitions_not_flagged() {
3837 let rule = MD057ExistingRelativeLinks::default();
3840
3841 let content = r#"# Title
3842
3843A footnote[^1].
3844
3845[^1]: [link](https://www.google.com).
3846"#;
3847
3848 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3849 let result = rule.check(&ctx).unwrap();
3850
3851 assert!(
3852 result.is_empty(),
3853 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3854 );
3855 }
3856
3857 #[test]
3858 fn test_footnote_with_relative_link_inside() {
3859 let rule = MD057ExistingRelativeLinks::default();
3862
3863 let content = r#"# Title
3864
3865See the footnote[^1].
3866
3867[^1]: Check out [this file](./existing.md) for more info.
3868[^2]: Also see [missing](./does-not-exist.md).
3869"#;
3870
3871 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3872 let result = rule.check(&ctx).unwrap();
3873
3874 for warning in &result {
3879 assert!(
3880 !warning.message.contains("[this file]"),
3881 "Footnote content should not be treated as URL: {warning:?}"
3882 );
3883 assert!(
3884 !warning.message.contains("[missing]"),
3885 "Footnote content should not be treated as URL: {warning:?}"
3886 );
3887 }
3888 }
3889
3890 #[test]
3891 fn test_mixed_footnotes_and_reference_definitions() {
3892 let temp_dir = tempdir().unwrap();
3894 let base_path = temp_dir.path();
3895
3896 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3897
3898 let content = r#"# Title
3899
3900A footnote[^1] and a [ref link][myref].
3901
3902[^1]: This is a footnote with [link](https://example.com).
3903
3904[myref]: ./missing-file.md "This should be checked"
3905"#;
3906
3907 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3908 let result = rule.check(&ctx).unwrap();
3909
3910 assert_eq!(
3912 result.len(),
3913 1,
3914 "Should only warn about the regular reference definition. Got: {result:?}"
3915 );
3916 assert!(
3917 result[0].message.contains("missing-file.md"),
3918 "Should warn about missing-file.md in reference definition"
3919 );
3920 }
3921
3922 #[test]
3923 fn test_absolute_links_ignore_by_default() {
3924 let temp_dir = tempdir().unwrap();
3926 let base_path = temp_dir.path();
3927
3928 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3929
3930 let content = r#"# Links
3931
3932[API docs](/api/v1/users)
3933[Blog post](/blog/2024/release.html)
3934
3935
3936[ref]: /docs/reference.md
3937"#;
3938
3939 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3940 let result = rule.check(&ctx).unwrap();
3941
3942 assert!(
3944 result.is_empty(),
3945 "Absolute links should be ignored by default. Got: {result:?}"
3946 );
3947 }
3948
3949 #[test]
3950 fn test_absolute_links_warn_config() {
3951 let temp_dir = tempdir().unwrap();
3953 let base_path = temp_dir.path();
3954
3955 let config = MD057Config {
3956 absolute_links: AbsoluteLinksOption::Warn,
3957 ..Default::default()
3958 };
3959 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3960
3961 let content = r#"# Links
3962
3963[API docs](/api/v1/users)
3964[Blog post](/blog/2024/release.html)
3965"#;
3966
3967 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3968 let result = rule.check(&ctx).unwrap();
3969
3970 assert_eq!(
3972 result.len(),
3973 2,
3974 "Should warn about both absolute links. Got: {result:?}"
3975 );
3976 assert!(
3977 result[0].message.contains("cannot be validated locally"),
3978 "Warning should explain why: {}",
3979 result[0].message
3980 );
3981 assert!(
3982 result[0].message.contains("/api/v1/users"),
3983 "Warning should include the link path"
3984 );
3985 }
3986
3987 #[test]
3988 fn test_absolute_links_warn_images() {
3989 let temp_dir = tempdir().unwrap();
3991 let base_path = temp_dir.path();
3992
3993 let config = MD057Config {
3994 absolute_links: AbsoluteLinksOption::Warn,
3995 ..Default::default()
3996 };
3997 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3998
3999 let content = r#"# Images
4000
4001
4002"#;
4003
4004 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4005 let result = rule.check(&ctx).unwrap();
4006
4007 assert_eq!(
4008 result.len(),
4009 1,
4010 "Should warn about absolute image path. Got: {result:?}"
4011 );
4012 assert!(
4013 result[0].message.contains("/assets/logo.png"),
4014 "Warning should include the image path"
4015 );
4016 }
4017
4018 #[test]
4019 fn test_absolute_links_warn_reference_definitions() {
4020 let temp_dir = tempdir().unwrap();
4022 let base_path = temp_dir.path();
4023
4024 let config = MD057Config {
4025 absolute_links: AbsoluteLinksOption::Warn,
4026 ..Default::default()
4027 };
4028 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4029
4030 let content = r#"# Reference
4031
4032See the [docs][ref].
4033
4034[ref]: /docs/reference.md
4035"#;
4036
4037 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4038 let result = rule.check(&ctx).unwrap();
4039
4040 assert_eq!(
4041 result.len(),
4042 1,
4043 "Should warn about absolute reference definition. Got: {result:?}"
4044 );
4045 assert!(
4046 result[0].message.contains("/docs/reference.md"),
4047 "Warning should include the reference path"
4048 );
4049 }
4050
4051 #[test]
4052 fn test_search_paths_inline_link() {
4053 let temp_dir = tempdir().unwrap();
4054 let base_path = temp_dir.path();
4055
4056 let assets_dir = base_path.join("assets");
4058 std::fs::create_dir_all(&assets_dir).unwrap();
4059 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
4060
4061 let config = MD057Config {
4062 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4063 ..Default::default()
4064 };
4065 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4066
4067 let content = "# Test\n\n[Photo](photo.png)\n";
4068 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4069 let result = rule.check(&ctx).unwrap();
4070
4071 assert!(
4072 result.is_empty(),
4073 "Should find photo.png via search-paths. Got: {result:?}"
4074 );
4075 }
4076
4077 #[test]
4078 fn test_search_paths_image() {
4079 let temp_dir = tempdir().unwrap();
4080 let base_path = temp_dir.path();
4081
4082 let assets_dir = base_path.join("attachments");
4083 std::fs::create_dir_all(&assets_dir).unwrap();
4084 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
4085
4086 let config = MD057Config {
4087 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4088 ..Default::default()
4089 };
4090 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4091
4092 let content = "# Test\n\n\n";
4093 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4094 let result = rule.check(&ctx).unwrap();
4095
4096 assert!(
4097 result.is_empty(),
4098 "Should find diagram.svg via search-paths. Got: {result:?}"
4099 );
4100 }
4101
4102 #[test]
4103 fn test_search_paths_reference_definition() {
4104 let temp_dir = tempdir().unwrap();
4105 let base_path = temp_dir.path();
4106
4107 let assets_dir = base_path.join("images");
4108 std::fs::create_dir_all(&assets_dir).unwrap();
4109 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
4110
4111 let config = MD057Config {
4112 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4113 ..Default::default()
4114 };
4115 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4116
4117 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
4118 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4119 let result = rule.check(&ctx).unwrap();
4120
4121 assert!(
4122 result.is_empty(),
4123 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
4124 );
4125 }
4126
4127 #[test]
4128 fn test_search_paths_still_warns_when_truly_missing() {
4129 let temp_dir = tempdir().unwrap();
4130 let base_path = temp_dir.path();
4131
4132 let assets_dir = base_path.join("assets");
4133 std::fs::create_dir_all(&assets_dir).unwrap();
4134
4135 let config = MD057Config {
4136 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4137 ..Default::default()
4138 };
4139 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4140
4141 let content = "# Test\n\n\n";
4142 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4143 let result = rule.check(&ctx).unwrap();
4144
4145 assert_eq!(
4146 result.len(),
4147 1,
4148 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4149 );
4150 }
4151
4152 #[test]
4153 fn test_search_paths_nonexistent_directory() {
4154 let temp_dir = tempdir().unwrap();
4155 let base_path = temp_dir.path();
4156
4157 let config = MD057Config {
4158 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4159 ..Default::default()
4160 };
4161 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4162
4163 let content = "# Test\n\n\n";
4164 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4165 let result = rule.check(&ctx).unwrap();
4166
4167 assert_eq!(
4168 result.len(),
4169 1,
4170 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4171 );
4172 }
4173
4174 #[test]
4175 fn test_obsidian_attachment_folder_named() {
4176 let temp_dir = tempdir().unwrap();
4177 let vault = temp_dir.path().join("vault");
4178 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4179 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4180 std::fs::create_dir_all(vault.join("notes")).unwrap();
4181
4182 std::fs::write(
4183 vault.join(".obsidian/app.json"),
4184 r#"{"attachmentFolderPath": "Attachments"}"#,
4185 )
4186 .unwrap();
4187 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4188
4189 let notes_dir = vault.join("notes");
4190 let source_file = notes_dir.join("test.md");
4191 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
4192
4193 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4194
4195 let content = "# Test\n\n\n";
4196 let ctx =
4197 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4198 let result = rule.check(&ctx).unwrap();
4199
4200 assert!(
4201 result.is_empty(),
4202 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4203 );
4204 }
4205
4206 #[test]
4207 fn test_obsidian_attachment_same_folder_as_file() {
4208 let temp_dir = tempdir().unwrap();
4209 let vault = temp_dir.path().join("vault-rf");
4210 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4211 std::fs::create_dir_all(vault.join("notes")).unwrap();
4212
4213 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4214
4215 let notes_dir = vault.join("notes");
4217 let source_file = notes_dir.join("test.md");
4218 std::fs::write(&source_file, "placeholder").unwrap();
4219 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4220
4221 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4222
4223 let content = "# Test\n\n\n";
4224 let ctx =
4225 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4226 let result = rule.check(&ctx).unwrap();
4227
4228 assert!(
4229 result.is_empty(),
4230 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4231 );
4232 }
4233
4234 #[test]
4235 fn test_obsidian_not_triggered_without_obsidian_flavor() {
4236 let temp_dir = tempdir().unwrap();
4237 let vault = temp_dir.path().join("vault-nf");
4238 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4239 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4240 std::fs::create_dir_all(vault.join("notes")).unwrap();
4241
4242 std::fs::write(
4243 vault.join(".obsidian/app.json"),
4244 r#"{"attachmentFolderPath": "Attachments"}"#,
4245 )
4246 .unwrap();
4247 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4248
4249 let notes_dir = vault.join("notes");
4250 let source_file = notes_dir.join("test.md");
4251 std::fs::write(&source_file, "placeholder").unwrap();
4252
4253 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4254
4255 let content = "# Test\n\n\n";
4256 let ctx =
4258 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4259 let result = rule.check(&ctx).unwrap();
4260
4261 assert_eq!(
4262 result.len(),
4263 1,
4264 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4265 );
4266 }
4267
4268 #[test]
4269 fn test_search_paths_combined_with_obsidian() {
4270 let temp_dir = tempdir().unwrap();
4271 let vault = temp_dir.path().join("vault-combo");
4272 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4273 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4274 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4275 std::fs::create_dir_all(vault.join("notes")).unwrap();
4276
4277 std::fs::write(
4278 vault.join(".obsidian/app.json"),
4279 r#"{"attachmentFolderPath": "Attachments"}"#,
4280 )
4281 .unwrap();
4282 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4283 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4284
4285 let notes_dir = vault.join("notes");
4286 let source_file = notes_dir.join("test.md");
4287 std::fs::write(&source_file, "placeholder").unwrap();
4288
4289 let extra_assets_dir = vault.join("extra-assets");
4290 let config = MD057Config {
4291 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4292 ..Default::default()
4293 };
4294 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4295
4296 let content = "# Test\n\n\n\n\n";
4298 let ctx =
4299 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4300 let result = rule.check(&ctx).unwrap();
4301
4302 assert!(
4303 result.is_empty(),
4304 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4305 );
4306 }
4307
4308 #[test]
4309 fn test_obsidian_attachment_subfolder_under_file() {
4310 let temp_dir = tempdir().unwrap();
4311 let vault = temp_dir.path().join("vault-sub");
4312 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4313 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4314
4315 std::fs::write(
4316 vault.join(".obsidian/app.json"),
4317 r#"{"attachmentFolderPath": "./assets"}"#,
4318 )
4319 .unwrap();
4320 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4321
4322 let notes_dir = vault.join("notes");
4323 let source_file = notes_dir.join("test.md");
4324 std::fs::write(&source_file, "placeholder").unwrap();
4325
4326 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4327
4328 let content = "# Test\n\n\n";
4329 let ctx =
4330 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4331 let result = rule.check(&ctx).unwrap();
4332
4333 assert!(
4334 result.is_empty(),
4335 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4336 );
4337 }
4338
4339 #[test]
4340 fn test_obsidian_attachment_vault_root() {
4341 let temp_dir = tempdir().unwrap();
4342 let vault = temp_dir.path().join("vault-root");
4343 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4344 std::fs::create_dir_all(vault.join("notes")).unwrap();
4345
4346 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4348 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4349
4350 let notes_dir = vault.join("notes");
4351 let source_file = notes_dir.join("test.md");
4352 std::fs::write(&source_file, "placeholder").unwrap();
4353
4354 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4355
4356 let content = "# Test\n\n\n";
4357 let ctx =
4358 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4359 let result = rule.check(&ctx).unwrap();
4360
4361 assert!(
4362 result.is_empty(),
4363 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4364 );
4365 }
4366
4367 #[test]
4368 fn test_search_paths_multiple_directories() {
4369 let temp_dir = tempdir().unwrap();
4370 let base_path = temp_dir.path();
4371
4372 let dir_a = base_path.join("dir-a");
4373 let dir_b = base_path.join("dir-b");
4374 std::fs::create_dir_all(&dir_a).unwrap();
4375 std::fs::create_dir_all(&dir_b).unwrap();
4376 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4377 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4378
4379 let config = MD057Config {
4380 search_paths: vec![
4381 dir_a.to_string_lossy().into_owned(),
4382 dir_b.to_string_lossy().into_owned(),
4383 ],
4384 ..Default::default()
4385 };
4386 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4387
4388 let content = "# Test\n\n\n\n\n";
4389 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4390 let result = rule.check(&ctx).unwrap();
4391
4392 assert!(
4393 result.is_empty(),
4394 "Should find files across multiple search paths. Got: {result:?}"
4395 );
4396 }
4397
4398 #[test]
4407 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4408 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4409
4410 let temp_dir = tempdir().unwrap();
4411 let base_path = temp_dir.path();
4412
4413 let file_path = base_path.join("README.md");
4414 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4415 std::fs::write(&file_path, content).unwrap();
4416
4417 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4418
4419 let ctx = crate::lint_context::LintContext::new(
4420 content,
4421 crate::config::MarkdownFlavor::Standard,
4422 Some(file_path.clone()),
4423 );
4424 let per_file = rule.check(&ctx).unwrap();
4425 assert_eq!(
4426 per_file.len(),
4427 1,
4428 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4429 );
4430
4431 let mut file_index = FileIndex::default();
4432 file_index.cross_file_links.push(CrossFileLinkIndex {
4433 target_path: "missing-guide.md".to_string(),
4434 fragment: String::new(),
4435 line: 3,
4436 column: 1,
4437 origin: LinkOrigin::Body,
4438 });
4439
4440 let result = rule
4441 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4442 .unwrap();
4443
4444 assert!(
4445 result.is_empty(),
4446 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4447 );
4448 }
4449
4450 #[test]
4451 fn test_check_clears_stale_cache() {
4452 let temp_dir = tempdir().unwrap();
4455 let base_path = temp_dir.path();
4456
4457 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4458
4459 let phantom_path = base_path.join("phantom.md");
4461 {
4462 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4463 cache.insert(phantom_path.clone(), true);
4464 }
4465
4466 let content = "[phantom](phantom.md)\n";
4467 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4468 let warnings = rule.check(&ctx).unwrap();
4469
4470 assert_eq!(
4472 warnings.len(),
4473 1,
4474 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4475 );
4476 assert!(warnings[0].message.contains("phantom.md"));
4477 }
4478
4479 #[test]
4480 fn test_check_does_not_carry_over_cache_between_runs() {
4481 let temp_dir = tempdir().unwrap();
4483 let base_path = temp_dir.path();
4484
4485 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4486
4487 let content = "[missing](nonexistent.md)\n";
4488 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4489
4490 let warnings_1 = rule.check(&ctx).unwrap();
4492 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4493
4494 let nonexistent_path = base_path.join("nonexistent.md");
4496 {
4497 let mut cache = FILE_EXISTENCE_CACHE.lock().unwrap();
4498 cache.insert(nonexistent_path.clone(), true);
4499 }
4500
4501 let warnings_2 = rule.check(&ctx).unwrap();
4503 assert_eq!(
4504 warnings_2.len(),
4505 1,
4506 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4507 );
4508 }
4509
4510 #[test]
4516 fn test_no_duplicate_warnings_for_broken_relative_link() {
4517 use crate::workspace_index::WorkspaceIndex;
4518
4519 let temp_dir = tempdir().unwrap();
4520 let base_path = temp_dir.path();
4521
4522 let source_file = base_path.join("index.md");
4524 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4525
4526 let content = "[broken](does/not/exist.md)\n";
4527
4528 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4529
4530 let ctx = crate::lint_context::LintContext::new(
4532 content,
4533 crate::config::MarkdownFlavor::Standard,
4534 Some(source_file.clone()),
4535 );
4536 let check_warnings = rule.check(&ctx).unwrap();
4537
4538 let mut file_index = FileIndex::new();
4540 rule.contribute_to_index(&ctx, &mut file_index);
4541 let workspace_index = WorkspaceIndex::new();
4542 let cross_warnings = rule
4543 .cross_file_check(&source_file, &file_index, &workspace_index)
4544 .unwrap();
4545
4546 let total = check_warnings.len() + cross_warnings.len();
4547 assert_eq!(
4548 total, 1,
4549 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4550 check={check_warnings:?}, cross={cross_warnings:?}"
4551 );
4552 }
4553
4554 #[test]
4559 fn test_absolute_dir_link_accepted_relative_to_roots() {
4560 let temp_dir = tempdir().unwrap();
4561 let root = temp_dir.path();
4562
4563 let dir_d = root.join("d");
4565 std::fs::create_dir_all(&dir_d).unwrap();
4566 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4567
4568 let content = "\
4571[absolute dir](/d)\n\
4572[relative dir](d)\n\
4573[absolute file](/d/foo.md)\n\
4574[relative file](d/foo.md)\n";
4575
4576 let config = MD057Config {
4577 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4578 roots: vec![],
4579 ..Default::default()
4580 };
4581 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4582
4583 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4584 let result = rule.check(&ctx).unwrap();
4585
4586 assert!(
4587 result.is_empty(),
4588 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4589 );
4590 }
4591
4592 #[test]
4598 fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4599 let temp_dir = tempdir().unwrap();
4600 let root = temp_dir.path();
4601
4602 let dir_d = root.join("d");
4604 std::fs::create_dir_all(&dir_d).unwrap();
4605 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4606
4607 let content = "\
4608[no slash](/d)\n\
4609[trailing slash](/d/)\n\
4610[trailing slash and fragment](/d/#intro)\n\
4611[relative](d/)\n";
4612
4613 let config = MD057Config {
4614 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4615 roots: vec![],
4616 ..Default::default()
4617 };
4618 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4619
4620 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4621 let result = rule.check(&ctx).unwrap();
4622
4623 assert!(
4624 result.is_empty(),
4625 "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4626 );
4627 }
4628
4629 #[test]
4632 fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4633 let temp_dir = tempdir().unwrap();
4634 let root = temp_dir.path();
4635
4636 let content = "[gone](/nodir/)\n";
4637
4638 let config = MD057Config {
4639 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4640 roots: vec![],
4641 ..Default::default()
4642 };
4643 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4644
4645 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4646 let result = rule.check(&ctx).unwrap();
4647
4648 assert_eq!(
4649 result.len(),
4650 1,
4651 "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4652 );
4653 }
4654
4655 #[test]
4659 fn test_docs_dir_variant_still_enforces_index_md() {
4660 let temp_dir = tempdir().unwrap();
4661 let root = temp_dir.path();
4662
4663 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4665
4666 let docs_dir = root.join("docs");
4668 std::fs::create_dir_all(&docs_dir).unwrap();
4669 let section_dir = docs_dir.join("section");
4670 std::fs::create_dir_all(§ion_dir).unwrap();
4671 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4672
4673 let source_file = docs_dir.join("index.md");
4675 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4676
4677 let config = MD057Config {
4678 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4679 ..Default::default()
4680 };
4681 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4682
4683 let content = "[sec](/section)\n";
4684 let ctx = crate::lint_context::LintContext::new(
4685 content,
4686 crate::config::MarkdownFlavor::Standard,
4687 Some(source_file.clone()),
4688 );
4689 let result = rule.check(&ctx).unwrap();
4690
4691 assert_eq!(
4693 result.len(),
4694 1,
4695 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4696 );
4697 assert!(
4698 result[0].message.contains("index.md") || result[0].message.contains("section"),
4699 "Message should mention the directory or missing index.md: {}",
4700 result[0].message
4701 );
4702 }
4703
4704 #[test]
4709 fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4710 let temp_dir = tempdir().unwrap();
4711 let root = temp_dir.path();
4712 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4713
4714 let docs_dir = root.join("docs");
4716 let guide_dir = docs_dir.join("guide");
4717 std::fs::create_dir_all(&guide_dir).unwrap();
4718 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4719
4720 let source_file = docs_dir.join("t.md");
4721 let content = "\
4722[no slash](/guide)\n\
4723[trailing slash](/guide/)\n\
4724[trailing slash and fragment](/guide/#intro)\n";
4725 std::fs::write(&source_file, content).unwrap();
4726
4727 let config = MD057Config {
4728 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4729 ..Default::default()
4730 };
4731 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4732 let ctx = crate::lint_context::LintContext::new(
4733 content,
4734 crate::config::MarkdownFlavor::Standard,
4735 Some(source_file.clone()),
4736 );
4737 let result = rule.check(&ctx).unwrap();
4738
4739 assert_eq!(
4740 result.len(),
4741 3,
4742 "Every directory link must be routed through the index.md check. Got: {result:?}"
4743 );
4744 for warning in &result {
4745 assert!(
4746 warning.message.contains("which has no index.md"),
4747 "The message must name the reason, not report the directory as missing: {}",
4748 warning.message
4749 );
4750 }
4751 }
4752}
4753
4754#[cfg(test)]
4755mod self_referential_links_tests {
4756 use super::*;
4757 use tempfile::tempdir;
4758
4759 pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4761 let source_file = dir.join(name);
4762 std::fs::write(&source_file, content).unwrap();
4763 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4764 let ctx =
4765 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4766 rule.check(&ctx).unwrap()
4767 }
4768
4769 fn enabled() -> MD057Config {
4770 MD057Config {
4771 self_referential_links: true,
4772 ..Default::default()
4773 }
4774 }
4775
4776 #[test]
4777 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4778 let temp_dir = tempdir().unwrap();
4779 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4780 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4781
4782 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4783 assert_eq!(
4784 result[0].message,
4785 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4786 );
4787 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4788 assert_eq!(fix.replacement, "#level-2-heading");
4789 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4790 }
4791
4792 #[test]
4793 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4794 let temp_dir = tempdir().unwrap();
4795 let content = "# Title\n\nSee [this file](test.md).\n";
4796 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4797
4798 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4799 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4800 assert!(
4801 result[0].fix.is_none(),
4802 "Dropping the link would change the document, so there is no fix"
4803 );
4804 }
4805
4806 #[test]
4807 fn test_the_check_is_off_by_default() {
4808 let temp_dir = tempdir().unwrap();
4809 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4810 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4811
4812 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4813 }
4814
4815 #[test]
4816 fn test_a_link_to_another_file_is_left_alone() {
4817 let temp_dir = tempdir().unwrap();
4818 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4819 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4820 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4821
4822 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4823 }
4824
4825 #[test]
4826 fn test_a_self_link_written_with_traversal_reports_once() {
4827 let temp_dir = tempdir().unwrap();
4828 let sub_dir = temp_dir.path().join("sub");
4829 std::fs::create_dir_all(&sub_dir).unwrap();
4830
4831 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4832 let config = MD057Config {
4833 self_referential_links: true,
4834 compact_paths: true,
4835 ..Default::default()
4836 };
4837 let result = check_as_file(&sub_dir, "test.md", content, config);
4838
4839 assert_eq!(
4840 result.len(),
4841 1,
4842 "A compacted path would still be a link back to this file. Got: {result:?}"
4843 );
4844 assert_eq!(
4845 result[0].message,
4846 "Relative link '../sub/test.md' points to the file it is in"
4847 );
4848 }
4849
4850 #[test]
4851 fn test_compact_paths_still_reports_a_link_to_another_file() {
4852 let temp_dir = tempdir().unwrap();
4853 let sub_dir = temp_dir.path().join("sub");
4854 std::fs::create_dir_all(&sub_dir).unwrap();
4855 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4856
4857 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4858 let config = MD057Config {
4859 self_referential_links: true,
4860 compact_paths: true,
4861 ..Default::default()
4862 };
4863 let result = check_as_file(&sub_dir, "test.md", content, config);
4864
4865 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4866 assert_eq!(
4867 result[0].message,
4868 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4869 );
4870 }
4871
4872 #[test]
4873 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4874 let temp_dir = tempdir().unwrap();
4875 let content = "# Title\n\nSee [this file](test#title).\n";
4876 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4877
4878 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4879 assert_eq!(
4880 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4881 Some("#title"),
4882 "Got: {result:?}"
4883 );
4884 }
4885
4886 #[test]
4887 fn test_a_reference_definition_pointing_at_its_own_file() {
4888 let temp_dir = tempdir().unwrap();
4889 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4890 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4891
4892 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4893 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4894 assert_eq!(fix.replacement, "#level-2-heading");
4895 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4896 }
4897
4898 #[test]
4899 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4900 let temp_dir = tempdir().unwrap();
4901 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4902 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4903
4904 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4905 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4906 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4909 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4910 .fix(&crate::lint_context::LintContext::new(
4911 content,
4912 crate::config::MarkdownFlavor::Standard,
4913 Some(temp_dir.path().join("test.md")),
4914 ))
4915 .unwrap();
4916 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4917 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4918 }
4919
4920 #[test]
4921 fn test_a_self_link_resolved_through_a_search_path() {
4922 let temp_dir = tempdir().unwrap();
4923 let guide_dir = temp_dir.path().join("docs/guide");
4924 std::fs::create_dir_all(&guide_dir).unwrap();
4925 let config = MD057Config {
4926 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4927 ..enabled()
4928 };
4929 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4930 let result = check_as_file(&guide_dir, "test.md", content, config);
4931
4932 assert_eq!(
4933 result.len(),
4934 1,
4935 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4936 );
4937 assert_eq!(
4938 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4939 Some("#title"),
4940 "Got: {result:?}"
4941 );
4942 }
4943
4944 #[test]
4945 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4946 let temp_dir = tempdir().unwrap();
4947 let guide_dir = temp_dir.path().join("docs/guide");
4948 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4949 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4950 let config = MD057Config {
4951 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4952 ..enabled()
4953 };
4954 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4955 let result = check_as_file(&guide_dir, "test.md", content, config);
4956
4957 assert!(
4958 result.is_empty(),
4959 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4960 );
4961 }
4962
4963 #[test]
4964 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4965 let temp_dir = tempdir().unwrap();
4966 let content = "# Title\n\n\n";
4967 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4968
4969 assert!(
4970 result.is_empty(),
4971 "An image is not a link the reader follows. Got: {result:?}"
4972 );
4973 }
4974
4975 #[test]
4976 fn test_a_query_string_is_reported_without_a_suggestion() {
4977 let temp_dir = tempdir().unwrap();
4978 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4979 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4980
4981 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4982 assert!(
4983 result[0].fix.is_none(),
4984 "A query does not survive losing its path. Got: {result:?}"
4985 );
4986 }
4987
4988 #[test]
4989 fn test_fix_rewrites_the_document_and_settles() {
4990 let temp_dir = tempdir().unwrap();
4991 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4992 let source_file = temp_dir.path().join("test.md");
4993 std::fs::write(&source_file, content).unwrap();
4994
4995 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4996 let ctx = crate::lint_context::LintContext::new(
4997 content,
4998 crate::config::MarkdownFlavor::Standard,
4999 Some(source_file.clone()),
5000 );
5001 let fixed = rule.fix(&ctx).unwrap();
5002 assert_eq!(
5003 fixed,
5004 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
5005 );
5006
5007 let refixed =
5008 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
5009 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
5010 }
5011
5012 #[test]
5013 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
5014 let unfixable = MD057ExistingRelativeLinks::default();
5015 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
5016
5017 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
5018 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
5019 }
5020
5021 #[test]
5022 fn test_the_option_is_read_from_kebab_and_snake_case() {
5023 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
5024 assert!(kebab.self_referential_links);
5025
5026 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
5027 assert!(snake.self_referential_links);
5028 }
5029
5030 fn front_matter_checked() -> MD057Config {
5031 MD057Config {
5032 check_frontmatter: true,
5033 ..Default::default()
5034 }
5035 }
5036
5037 #[test]
5038 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
5039 let temp_dir = tempdir().unwrap();
5040 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5041 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5042
5043 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5044 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5045 assert_eq!(result[0].line, 2);
5046 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5047 assert_eq!(result[0].end_column, 23);
5048 }
5049
5050 #[test]
5051 fn test_frontmatter_paths_are_not_checked_by_default() {
5052 let temp_dir = tempdir().unwrap();
5053 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5054 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5055
5056 assert!(
5057 result.is_empty(),
5058 "Frontmatter is only checked on request. Got: {result:?}"
5059 );
5060 }
5061
5062 #[test]
5063 fn test_an_existing_frontmatter_path_is_not_reported() {
5064 let temp_dir = tempdir().unwrap();
5065 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5066 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
5067 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5068
5069 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
5070 assert_eq!(result[0].line, 3);
5071 }
5072
5073 #[test]
5074 fn test_an_ignored_frontmatter_field_is_not_checked() {
5075 let temp_dir = tempdir().unwrap();
5076 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5077 let config = MD057Config {
5078 check_frontmatter: true,
5079 ignore_frontmatter_fields: vec!["Image".to_string()],
5080 ..Default::default()
5081 };
5082 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5083
5084 assert_eq!(
5085 result.len(),
5086 1,
5087 "The ignored field is skipped and the other is not. Got: {result:?}"
5088 );
5089 assert_eq!(result[0].line, 3);
5090 }
5091
5092 #[test]
5093 fn test_an_external_frontmatter_url_is_not_reported() {
5094 let temp_dir = tempdir().unwrap();
5095 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5096 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5097
5098 assert!(
5099 result.is_empty(),
5100 "An external URL has no local target. Got: {result:?}"
5101 );
5102 }
5103
5104 #[test]
5105 fn test_a_frontmatter_fragment_is_left_to_md051() {
5106 let temp_dir = tempdir().unwrap();
5107 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5108 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5109
5110 assert!(
5111 result.is_empty(),
5112 "A fragment names a heading, not a file. Got: {result:?}"
5113 );
5114 }
5115
5116 #[test]
5117 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5118 let temp_dir = tempdir().unwrap();
5119 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5120
5121 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5122 assert!(
5123 ignored.is_empty(),
5124 "Absolute paths are ignored by default. Got: {ignored:?}"
5125 );
5126
5127 let warning_config = MD057Config {
5128 check_frontmatter: true,
5129 absolute_links: AbsoluteLinksOption::Warn,
5130 ..Default::default()
5131 };
5132 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5133 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5134 assert_eq!(
5135 warned[0].message,
5136 "Absolute link '/docs/guide.md' cannot be validated locally"
5137 );
5138 }
5139
5140 #[test]
5141 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5142 let temp_dir = tempdir().unwrap();
5143 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5144 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5145 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5146
5147 assert_eq!(
5148 result.len(),
5149 1,
5150 "A query names no file, so only the missing target is reported. Got: {result:?}"
5151 );
5152 assert_eq!(result[0].line, 2);
5153 assert_eq!(
5154 result[0].message,
5155 "Relative link 'docs/missing.md?raw=true' does not exist"
5156 );
5157 }
5158
5159 #[test]
5160 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5161 let temp_dir = tempdir().unwrap();
5162 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5163 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5164
5165 assert!(
5166 result.is_empty(),
5167 "Only path-shaped values are destinations. Got: {result:?}"
5168 );
5169 }
5170}
5171
5172#[cfg(test)]
5177mod wrapped_link_text_tests {
5178 use super::self_referential_links_tests::check_as_file;
5179 use super::*;
5180 use tempfile::tempdir;
5181
5182 #[test]
5183 fn test_a_wrapped_link_in_a_list_item_is_reported() {
5184 let temp_dir = tempdir().unwrap();
5185 let content = "- Items reimbursable by the various [one-off\n expense](does-not-exist-anywhere)\n budgets.\n";
5186 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5187
5188 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5189 assert_eq!(
5190 result[0].message,
5191 "Relative link 'does-not-exist-anywhere' does not exist"
5192 );
5193 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5194 assert_eq!(result[0].end_line, 2);
5195 assert_eq!(result[0].column, 12);
5198 assert_eq!(result[0].end_column, 35);
5199 }
5200
5201 #[test]
5202 fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5203 let temp_dir = tempdir().unwrap();
5204 let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5205 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5206
5207 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5208 assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5209 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5210 assert_eq!(result[0].column, 7);
5213 assert_eq!(result[0].end_column, 19);
5214 }
5215
5216 #[test]
5217 fn test_a_wrapped_link_carrying_a_title_is_reported() {
5218 let temp_dir = tempdir().unwrap();
5219 let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5220 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5221
5222 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5223 assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5224 assert_eq!(result[0].line, 2);
5225 assert_eq!(result[0].column, 7);
5228 assert_eq!(result[0].end_column, 24);
5229 }
5230
5231 #[test]
5232 fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5233 let temp_dir = tempdir().unwrap();
5234 std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5235 let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5236 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5237
5238 assert!(result.is_empty(), "The target exists. Got: {result:?}");
5239 }
5240
5241 #[test]
5242 fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5243 let rule = MD057ExistingRelativeLinks::new();
5244 let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5245
5246 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5247 let mut index = FileIndex::new();
5248 rule.contribute_to_index(&ctx, &mut index);
5249
5250 let targets: Vec<&str> = index
5251 .md057_link_targets
5252 .iter()
5253 .map(|target| target.target.as_str())
5254 .collect();
5255 assert_eq!(
5256 targets,
5257 vec!["./docs/guide.md"],
5258 "The index must record the target so a cached verdict is invalidated when the file appears"
5259 );
5260 }
5261
5262 #[test]
5263 fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5264 let temp_dir = tempdir().unwrap();
5265 let sub_dir = temp_dir.path().join("sub");
5266 std::fs::create_dir_all(&sub_dir).unwrap();
5267 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5268
5269 let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5270 let config = MD057Config {
5271 compact_paths: true,
5272 ..Default::default()
5273 };
5274 let result = check_as_file(&sub_dir, "test.md", content, config);
5275
5276 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5277 assert_eq!(
5278 result[0].message,
5279 "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5280 );
5281 assert_eq!(result[0].line, 2);
5282 let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5283 assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5284 assert_eq!(fix.replacement, "other.md#part");
5285 }
5286
5287 #[test]
5288 fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5289 let temp_dir = tempdir().unwrap();
5290 let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5291 let config = MD057Config {
5292 self_referential_links: true,
5293 ..Default::default()
5294 };
5295 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5296
5297 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5298 assert_eq!(
5299 result[0].message,
5300 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5301 );
5302 assert_eq!(result[0].line, 4);
5303 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5304 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5305 assert_eq!(fix.replacement, "#level-2-heading");
5306 }
5307}
5308
5309#[cfg(test)]
5315mod destination_boundary_tests {
5316 use super::self_referential_links_tests::check_as_file;
5317 use super::*;
5318 use tempfile::tempdir;
5319
5320 fn compacting() -> MD057Config {
5321 MD057Config {
5322 compact_paths: true,
5323 ..Default::default()
5324 }
5325 }
5326
5327 #[test]
5331 fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5332 let temp_dir = tempdir().unwrap();
5333 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5334 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5335
5336 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5337
5338 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5339 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5340 assert_eq!(result[0].line, 1);
5341 assert_eq!(result[0].column, 13);
5343 assert_eq!(result[0].end_column, 23);
5344 assert!(result[0].fix.is_none(), "A missing target carries no fix");
5345 }
5346
5347 #[test]
5350 fn test_compaction_never_rewrites_a_title() {
5351 let temp_dir = tempdir().unwrap();
5352 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5353 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5354
5355 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5356
5357 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5358 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5359 assert_eq!(result[0].column, 13);
5360 assert!(
5361 result.iter().all(|warning| warning.fix.is_none()),
5362 "Nothing in this document is rewritable. Got: {result:?}"
5363 );
5364 }
5365
5366 #[test]
5369 fn test_a_nested_bracket_does_not_close_the_label() {
5370 let temp_dir = tempdir().unwrap();
5371 let content = "[see [note]](./missing.md)\n";
5372
5373 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5374
5375 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5376 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5377 assert_eq!(result[0].column, 14);
5379 assert_eq!(result[0].end_column, 26);
5380 }
5381
5382 #[test]
5386 fn test_an_autolink_carries_no_destination() {
5387 let temp_dir = tempdir().unwrap();
5388 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5389 let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5390
5391 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5392
5393 assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5394 }
5395
5396 #[test]
5398 fn test_an_email_autolink_carries_no_destination() {
5399 let temp_dir = tempdir().unwrap();
5400 let content = "<user@example.com>\n";
5401
5402 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5403
5404 assert!(
5405 result.is_empty(),
5406 "An email autolink is not a relative link. Got: {result:?}"
5407 );
5408 }
5409
5410 #[test]
5415 fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5416 let temp_dir = tempdir().unwrap();
5417 std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5418 let content = "[``a\n](./exists.md)``](exists.md)\n";
5419
5420 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5421
5422 assert!(
5423 result.is_empty(),
5424 "Both destinations exist, so nothing is reported. Got: {result:?}"
5425 );
5426 assert!(
5427 result.iter().all(|warning| warning.fix.is_none()),
5428 "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5429 );
5430 }
5431
5432 #[test]
5436 fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5437 let temp_dir = tempdir().unwrap();
5438 let content = "[<i title=\"[\">text</i>](missing.md)\n";
5439
5440 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5441
5442 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5443 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5444 assert_eq!(result[0].line, 1);
5445 assert_eq!(result[0].column, 25);
5447 assert_eq!(result[0].end_column, 35);
5448 }
5449
5450 #[test]
5454 fn test_a_link_inside_an_image_description_is_not_a_link() {
5455 let temp_dir = tempdir().unwrap();
5456 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5457 let content = "](exists.png)\n";
5458
5459 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5460
5461 assert!(
5462 result.is_empty(),
5463 "The description of an image carries no link. Got: {result:?}"
5464 );
5465 }
5466
5467 #[test]
5471 fn test_a_link_inside_literal_brackets_is_still_a_link() {
5472 let temp_dir = tempdir().unwrap();
5473 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5474 let content = "[outer [inner](missing2.md)](exists.png)\n";
5475
5476 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5477
5478 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5479 assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5480 assert_eq!(result[0].line, 1);
5481 assert_eq!(result[0].column, 16);
5483 }
5484
5485 #[test]
5488 fn test_an_image_of_its_own_is_still_reported() {
5489 let temp_dir = tempdir().unwrap();
5490 let content = "\n";
5491
5492 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5493
5494 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5495 assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5496 assert_eq!(result[0].column, 1);
5497 }
5498
5499 #[test]
5502 fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5503 let temp_dir = tempdir().unwrap();
5504 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5505 let content = "[](missing.md)\n";
5506
5507 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5508
5509 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5510 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5511 assert_eq!(result[0].column, 22);
5513 }
5514
5515 #[test]
5519 fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5520 let temp_dir = tempdir().unwrap();
5521 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5522 let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5523
5524 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5525
5526 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5527 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5528 assert_eq!(result[0].line, 1);
5529 assert_eq!(result[0].column, 12);
5531 assert_eq!(result[0].end_column, 22);
5532 }
5533
5534 #[test]
5539 fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5540 for content in [
5541 "][nodef]\n",
5542 "][]\n",
5543 "]\n",
5544 ] {
5545 let temp_dir = tempdir().unwrap();
5546
5547 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5548
5549 assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5550 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5551 assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5553 }
5554 }
5555
5556 #[test]
5559 fn test_an_image_with_an_empty_destination_is_still_an_image() {
5560 let temp_dir = tempdir().unwrap();
5561 let content = "]()\n";
5562
5563 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5564
5565 assert!(
5566 result.is_empty(),
5567 "An image with no destination is still an image. Got: {result:?}"
5568 );
5569 }
5570
5571 #[test]
5575 fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5576 let temp_dir = tempdir().unwrap();
5577 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5578 let content = " [link](missing.md)](exists.png)\n";
5579
5580 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5581
5582 assert!(
5583 result.is_empty(),
5584 "The link sits inside the outer image's description. Got: {result:?}"
5585 );
5586 }
5587
5588 #[test]
5591 fn test_a_link_after_an_image_is_a_link() {
5592 let temp_dir = tempdir().unwrap();
5593 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5594 let content = " [link](missing.md)\n";
5595
5596 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5597
5598 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5599 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5600 assert_eq!(result[0].column, 29);
5602 }
5603
5604 #[test]
5606 fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5607 let temp_dir = tempdir().unwrap();
5608 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5609 let content = "][def]\n\n[def]: exists.png\n";
5610
5611 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5612
5613 assert!(
5614 result.is_empty(),
5615 "The description of a resolved reference image carries no link. Got: {result:?}"
5616 );
5617 }
5618
5619 #[test]
5622 fn test_a_destination_on_the_next_line_is_reported_there() {
5623 let temp_dir = tempdir().unwrap();
5624 let content = "[a](\n ./gone.md)\n";
5625
5626 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5627
5628 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5629 assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5630 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5631 assert_eq!(result[0].end_line, 2);
5632 assert_eq!(result[0].column, 3);
5634 assert_eq!(result[0].end_column, 12);
5635 }
5636}
5637
5638#[cfg(test)]
5646mod exact_case_tests {
5647 use super::self_referential_links_tests::check_as_file;
5648 use super::*;
5649 use tempfile::tempdir;
5650
5651 fn volume_folds_case(dir: &Path) -> bool {
5656 let probe = dir.join("case-fold-probe.tmp");
5657 std::fs::write(&probe, "").unwrap();
5658 let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5659 std::fs::remove_file(&probe).unwrap();
5660 folded
5661 }
5662
5663 fn write_case_fixture(dir: &Path) {
5667 std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5668 std::fs::create_dir_all(dir.join("Docs")).unwrap();
5669 std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5670 }
5671
5672 #[test]
5678 fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5679 let temp_dir = tempdir().unwrap();
5680 let anchor = temp_dir.path();
5681 write_case_fixture(anchor);
5682 reset_file_existence_cache();
5683
5684 assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5685 assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5686 }
5687
5688 #[test]
5689 fn test_only_the_stored_spelling_of_a_target_exists() {
5690 let temp_dir = tempdir().unwrap();
5691 let anchor = temp_dir.path();
5692 write_case_fixture(anchor);
5693 reset_file_existence_cache();
5694
5695 for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5696 assert!(
5697 exists_exact_case(anchor, &anchor.join(target)),
5698 "{target} is on disk under this spelling"
5699 );
5700 }
5701 for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5702 assert!(
5703 !exists_exact_case(anchor, &anchor.join(target)),
5704 "{target} is not on disk under this spelling"
5705 );
5706 }
5707 }
5708
5709 #[test]
5712 fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5713 let temp_dir = tempdir().unwrap();
5714 let anchor = temp_dir.path();
5715 write_case_fixture(anchor);
5716 reset_file_existence_cache();
5717
5718 assert!(file_exists_or_markdown_extension(anchor, &anchor.join("Foo")));
5719 assert!(!file_exists_or_markdown_extension(anchor, &anchor.join("foo")));
5720 }
5721
5722 #[test]
5725 fn test_a_target_reached_through_a_parent_step_is_checked() {
5726 let temp_dir = tempdir().unwrap();
5727 write_case_fixture(temp_dir.path());
5728 let anchor = temp_dir.path().join("Docs");
5729 reset_file_existence_cache();
5730
5731 assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5732 assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5733 }
5734
5735 #[test]
5738 fn test_a_component_above_the_anchor_is_accepted() {
5739 let temp_dir = tempdir().unwrap();
5740 write_case_fixture(temp_dir.path());
5741 let anchor = temp_dir.path().join("docs");
5742 reset_file_existence_cache();
5743
5744 assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5745 }
5746
5747 #[cfg(unix)]
5751 #[test]
5752 fn test_a_link_through_a_symlinked_directory_exists() {
5753 let temp_dir = tempdir().unwrap();
5754 let anchor = temp_dir.path();
5755 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5756 std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5757 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5758 reset_file_existence_cache();
5759
5760 assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5761 }
5762
5763 #[cfg(target_os = "macos")]
5766 #[test]
5767 fn test_a_decomposed_name_matches_a_composed_link() {
5768 let temp_dir = tempdir().unwrap();
5769 let anchor = temp_dir.path();
5770 std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5771 reset_file_existence_cache();
5772
5773 assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5774 assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5775 }
5776
5777 #[test]
5778 fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5779 let temp_dir = tempdir().unwrap();
5780 write_case_fixture(temp_dir.path());
5781 let content = "\
5782[a](foo.md)\n\
5783[b](docs/Guide.md)\n\
5784[c](foo)\n\
5785[d](docs)\n\
5786[e](Foo.md)\n\
5787[f](Docs/Guide.md)\n\
5788[g](Docs)\n\
5789[h](Bar.md)\n";
5790
5791 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5792
5793 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5794 assert_eq!(
5795 messages,
5796 vec![
5797 "Relative link 'foo.md' does not exist",
5798 "Relative link 'docs/Guide.md' does not exist",
5799 "Relative link 'foo' does not exist",
5800 "Relative link 'docs' does not exist",
5801 "Relative link 'Bar.md' does not exist",
5802 ],
5803 "Got: {result:?}"
5804 );
5805 }
5806
5807 #[test]
5810 fn test_the_reporters_link_is_reported() {
5811 let temp_dir = tempdir().unwrap();
5812 std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5813 let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5814
5815 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5816
5817 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5818 assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5819 assert_eq!(result[0].line, 3);
5820 assert_eq!(result[0].column, 21);
5821 }
5822
5823 #[test]
5826 fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5827 let temp_dir = tempdir().unwrap();
5828 std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5829 let content = "[a](Guide.html)\n[b](guide.html)\n";
5830
5831 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5832
5833 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5834 assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5835 assert_eq!(result[0].line, 2);
5836 }
5837
5838 #[test]
5841 fn test_a_search_path_answers_for_the_exact_spelling() {
5842 let temp_dir = tempdir().unwrap();
5843 let assets = temp_dir.path().join("assets");
5844 std::fs::create_dir_all(&assets).unwrap();
5845 std::fs::write(assets.join("Logo.png"), "").unwrap();
5846 let config = MD057Config {
5847 search_paths: vec![assets.to_string_lossy().into_owned()],
5848 ..Default::default()
5849 };
5850 let content = "[a](Logo.png)\n[b](logo.png)\n";
5851
5852 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5853
5854 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5855 assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5856 assert_eq!(result[0].line, 2);
5857 }
5858
5859 #[test]
5862 fn test_an_absolute_link_under_a_root_keeps_its_case() {
5863 let temp_dir = tempdir().unwrap();
5864 let root = temp_dir.path();
5865 write_case_fixture(root);
5866 let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5867
5868 let config = MD057Config {
5869 absolute_links: AbsoluteLinksOption::RelativeToRoots,
5870 roots: vec![],
5871 ..Default::default()
5872 };
5873 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5874 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5875 let result = rule.check(&ctx).unwrap();
5876
5877 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5878 assert_eq!(
5879 messages,
5880 vec![
5881 "Absolute link '/docs/Guide.md' was not found under the project root",
5882 "Absolute link '/docs' was not found under the project root",
5883 ],
5884 "Got: {result:?}"
5885 );
5886 }
5887
5888 #[cfg(unix)]
5893 #[test]
5894 fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5895 let temp_dir = tempdir().unwrap();
5896 let anchor = temp_dir.path();
5897 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5898 std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5899 std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5900 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5901 let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5902
5903 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5904
5905 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5906 assert_eq!(
5907 messages,
5908 vec![
5909 "Relative link 'docs/../Foo.md' does not exist",
5910 "Relative link 'docs/bar.md' does not exist",
5911 ],
5912 "Got: {result:?}"
5913 );
5914 }
5915
5916 #[cfg(unix)]
5921 struct RestoreMode {
5922 directory: PathBuf,
5923 mode: u32,
5924 }
5925
5926 #[cfg(unix)]
5927 impl RestoreMode {
5928 fn take(directory: &Path) -> Self {
5929 use std::os::unix::fs::PermissionsExt;
5930 Self {
5931 directory: directory.to_path_buf(),
5932 mode: std::fs::metadata(directory).unwrap().permissions().mode(),
5933 }
5934 }
5935 }
5936
5937 #[cfg(unix)]
5938 impl Drop for RestoreMode {
5939 fn drop(&mut self) {
5940 use std::os::unix::fs::PermissionsExt;
5941 let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
5942 }
5943 }
5944
5945 #[cfg(unix)]
5949 #[test]
5950 fn test_a_directory_that_cannot_be_listed_is_accepted() {
5951 use std::os::unix::fs::PermissionsExt;
5952
5953 let temp_dir = tempdir().unwrap();
5954 let anchor = temp_dir.path();
5955 let locked = anchor.join("locked");
5956 std::fs::create_dir_all(&locked).unwrap();
5957 std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
5958
5959 let _restore = RestoreMode::take(&locked);
5960 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
5961 if std::fs::read_dir(&locked).is_ok() {
5962 return;
5965 }
5966 assert!(locked.join("Target.md").exists(), "the directory can still be entered");
5967 let content = "[a](locked/Target.md)\n";
5968
5969 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5970
5971 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
5972 }
5973
5974 #[cfg(target_os = "macos")]
5978 #[test]
5979 fn test_a_composed_name_matches_a_decomposed_link() {
5980 let temp_dir = tempdir().unwrap();
5981 let anchor = temp_dir.path();
5982 std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
5983 reset_file_existence_cache();
5984
5985 assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
5986 assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
5987
5988 let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
5989
5990 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5991
5992 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5993 assert_eq!(
5994 messages,
5995 vec!["Relative link 'CAFE\u{301}.md' does not exist"],
5996 "Got: {result:?}"
5997 );
5998 }
5999
6000 #[cfg(target_os = "macos")]
6005 #[test]
6006 fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
6007 let temp_dir = tempdir().unwrap();
6008 let anchor = temp_dir.path();
6009 std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
6010 reset_file_existence_cache();
6011
6012 assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
6013 assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
6014
6015 let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
6016
6017 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6018
6019 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6020 assert_eq!(
6021 messages,
6022 vec!["Relative link 'kelvin.md' does not exist"],
6023 "Got: {result:?}"
6024 );
6025 }
6026
6027 #[cfg(target_os = "macos")]
6031 #[test]
6032 fn test_an_ascii_name_matches_a_link_composing_to_it() {
6033 let temp_dir = tempdir().unwrap();
6034 let anchor = temp_dir.path();
6035 std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
6036 reset_file_existence_cache();
6037
6038 assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
6039 assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
6040
6041 let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.md)\n";
6042
6043 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6044
6045 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6046 assert_eq!(
6047 messages,
6048 vec!["Relative link '\u{212a}ELVIN.md' does not exist"],
6049 "Got: {result:?}"
6050 );
6051 }
6052
6053 fn seed_listing(directory: &Path, modified: SystemTime) {
6061 let listing = DirectoryListing {
6062 modified: Some(modified),
6063 listed: true,
6064 names: HashSet::from([OsString::from("Other.md")]),
6065 composed: HashSet::new(),
6066 };
6067 let key = std::fs::canonicalize(directory).unwrap();
6068 DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6069 }
6070
6071 #[test]
6076 fn test_a_listing_answers_until_the_directory_changes() {
6077 let temp_dir = tempdir().unwrap();
6078 let anchor = temp_dir.path();
6079 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6080 std::fs::write(anchor.join("test.md"), "").unwrap();
6083 let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6084 seed_listing(anchor, read_at);
6085
6086 let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6087
6088 let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6089 assert_eq!(
6090 messages,
6091 vec!["Relative link 'README.md' does not exist"],
6092 "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6093 );
6094
6095 std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6096 assert_ne!(
6097 std::fs::metadata(anchor).unwrap().modified().unwrap(),
6098 read_at,
6099 "creating an entry moves the directory's modification time"
6100 );
6101
6102 let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6103
6104 assert!(
6105 accepted.is_empty(),
6106 "the listing is read again once the directory has moved on. Got: {accepted:?}"
6107 );
6108 }
6109
6110 #[test]
6114 fn test_a_listing_from_another_moment_is_discarded() {
6115 let temp_dir = tempdir().unwrap();
6116 let anchor = temp_dir.path();
6117 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6118 seed_listing(anchor, SystemTime::UNIX_EPOCH);
6119
6120 let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6121
6122 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6123 }
6124}