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, extract_cross_file_links, link_path_part, normalize_relative_path,
13 url_decode,
14};
15use pulldown_cmark::LinkType;
16use regex::Regex;
17use std::cell::{LazyCell, RefCell};
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 link_resolver;
28mod md057_config;
29use crate::utils::mkdocs_config::resolve_docs_dir;
30use crate::utils::obsidian_config::resolve_attachment_folder;
31use crate::utils::project_root::project_root;
32#[cfg(test)]
33use link_resolver::is_external_url;
34pub use link_resolver::{DocumentLinks, LinkResolution, LinkResolver};
35pub use md057_config::{AbsoluteLinksOption, MD057Config};
36
37thread_local! {
38 static FILE_EXISTENCE_CACHE: RefCell<HashMap<PathBuf, bool>> = RefCell::new(HashMap::new());
43 static CHECK_DIRECTORY_LISTINGS: RefCell<Option<HashMap<PathBuf, Arc<DirectoryListing>>>> = const { RefCell::new(None) };
46}
47
48struct DirectoryCheck {
49 previous: Option<HashMap<PathBuf, Arc<DirectoryListing>>>,
50}
51
52impl DirectoryCheck {
53 fn begin() -> Self {
54 Self {
55 previous: CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| cache.replace(HashMap::new())),
56 }
57 }
58}
59
60impl Drop for DirectoryCheck {
61 fn drop(&mut self) {
62 CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| *cache = self.previous.take());
63 }
64}
65
66type DirectoryListingCache = Arc<Mutex<HashMap<PathBuf, Arc<DirectoryListing>>>>;
69
70static DIRECTORY_LISTING_CACHE: LazyLock<DirectoryListingCache> =
74 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
75
76fn reset_file_existence_cache() {
83 FILE_EXISTENCE_CACHE.with_borrow_mut(HashMap::clear);
84}
85
86struct DirectoryListing {
89 modified: Option<SystemTime>,
93 listed: bool,
96 names: HashSet<OsString>,
98 composed: HashSet<String>,
102}
103
104impl DirectoryListing {
105 fn unlisted(modified: Option<SystemTime>) -> Self {
108 Self {
109 modified,
110 listed: false,
111 names: HashSet::new(),
112 composed: HashSet::new(),
113 }
114 }
115
116 fn read(directory: &Path) -> Self {
117 let modified = std::fs::metadata(directory)
121 .and_then(|metadata| metadata.modified())
122 .ok();
123 let mut names = HashSet::new();
124 let mut composed = HashSet::new();
125 let Ok(entries) = std::fs::read_dir(directory) else {
126 return Self::unlisted(modified);
127 };
128 for entry in entries {
129 let Ok(entry) = entry else {
130 return Self::unlisted(modified);
134 };
135 let name = entry.file_name();
136 if let Some(text) = name.to_str()
137 && !text.is_ascii()
138 {
139 composed.insert(text.nfc().collect());
140 }
141 names.insert(name);
142 }
143 Self {
144 modified,
145 listed: true,
146 names,
147 composed,
148 }
149 }
150
151 fn holds(&self, name: &OsStr) -> bool {
158 if !self.listed {
159 return true;
160 }
161 if self.names.contains(name) {
162 return true;
163 }
164 let Some(text) = name.to_str() else {
165 return false;
166 };
167 if text.is_ascii() {
168 return self.composed.contains(text);
169 }
170 let composed: String = text.nfc().collect();
171 self.composed.contains(composed.as_str()) || self.names.contains(OsStr::new(composed.as_str()))
172 }
173}
174
175fn directory_listing(directory: &Path) -> Arc<DirectoryListing> {
192 if let Some(listing) =
193 CHECK_DIRECTORY_LISTINGS.with_borrow(|cache| cache.as_ref().and_then(|cache| cache.get(directory).cloned()))
194 {
195 return listing;
196 }
197 let listing = fresh_directory_listing(directory);
198 CHECK_DIRECTORY_LISTINGS.with_borrow_mut(|cache| {
199 if let Some(cache) = cache {
200 cache.insert(directory.to_path_buf(), Arc::clone(&listing));
201 }
202 });
203 listing
204}
205
206fn fresh_directory_listing(directory: &Path) -> Arc<DirectoryListing> {
207 let Ok(modified) = std::fs::metadata(directory).and_then(|metadata| metadata.modified()) else {
208 return Arc::new(DirectoryListing::read(directory));
211 };
212 match DIRECTORY_LISTING_CACHE.lock() {
213 Ok(cache) => {
214 if let Some(listing) = cache.get(directory)
215 && listing.modified == Some(modified)
216 {
217 return Arc::clone(listing);
218 }
219 }
220 Err(_) => return Arc::new(DirectoryListing::read(directory)), }
222 let listing = Arc::new(DirectoryListing::read(directory));
223 match DIRECTORY_LISTING_CACHE.lock() {
224 Ok(mut cache) => {
225 cache.insert(directory.to_path_buf(), Arc::clone(&listing));
232 listing
233 }
234 Err(_) => listing, }
236}
237
238fn file_exists_with_cache(path: &Path) -> bool {
240 if let Some(exists) = FILE_EXISTENCE_CACHE.with_borrow(|cache| cache.get(path).copied()) {
241 return exists;
242 }
243 let exists = path.exists();
244 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(path.to_path_buf(), exists));
245 exists
246}
247
248fn has_exact_case_components(anchor: &Path, path: &Path) -> bool {
261 let Ok(relative) = path.strip_prefix(anchor) else {
262 return true;
263 };
264 let mut directory = if anchor.as_os_str().is_empty() {
265 PathBuf::from(".")
266 } else {
267 anchor.to_path_buf()
268 };
269 for component in relative.components() {
270 match component {
271 Component::CurDir => {}
272 Component::ParentDir => {
273 if matches!(directory.components().next_back(), Some(Component::Normal(_))) {
277 directory.pop();
278 } else {
279 directory.push("..");
280 }
281 if directory.as_os_str().is_empty() {
284 directory.push(".");
285 }
286 }
287 Component::Normal(name) => {
288 if !directory_listing(&directory).holds(name) {
289 return false;
290 }
291 directory.push(name);
292 }
293 Component::RootDir | Component::Prefix(_) => return true,
296 }
297 }
298 true
299}
300
301fn exists_exact_case(anchor: &Path, path: &Path) -> bool {
310 file_exists_with_cache(path) && has_exact_case_components(anchor, path)
311}
312
313fn resolve_existing_target(anchor: &Path, path: &Path) -> Option<PathBuf> {
318 if exists_exact_case(anchor, path) {
320 return Some(path.to_path_buf());
321 }
322
323 if path.extension().is_none() {
325 for ext in MARKDOWN_EXTENSIONS {
326 let path_with_ext = path.with_extension(&ext[1..]);
328 if exists_exact_case(anchor, &path_with_ext) {
329 return Some(path_with_ext);
330 }
331 }
332 }
333
334 None
335}
336
337fn html_markdown_sources(path: &Path) -> impl Iterator<Item = PathBuf> + '_ {
340 let source = path
341 .extension()
342 .and_then(|ext| ext.to_str())
343 .filter(|ext| ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
344 .and(path.file_stem().and_then(|stem| stem.to_str()))
345 .zip(path.parent());
346 source.into_iter().flat_map(|(stem, parent)| {
347 MARKDOWN_EXTENSIONS
348 .iter()
349 .map(move |md_ext| parent.join(format!("{stem}{md_ext}")))
350 })
351}
352
353static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
357 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
358
359static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
361
362const MARKDOWN_EXTENSIONS: &[&str] = &[
364 ".md",
365 ".markdown",
366 ".mdx",
367 ".mkd",
368 ".mkdn",
369 ".mdown",
370 ".mdwn",
371 ".qmd",
372 ".rmd",
373];
374
375struct DocumentAnchor {
377 self_path: Option<PathBuf>,
381 base_path: PathBuf,
385 search_paths: Vec<PathBuf>,
387}
388
389type LazyAnchor<'a> = LazyCell<DocumentAnchor, Box<dyn FnOnce() -> DocumentAnchor + 'a>>;
394
395#[cfg(test)]
396thread_local! {
397 static ANCHOR_RESOLUTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
400}
401
402#[derive(Debug, PartialEq, Eq)]
404enum SelfReferentialLink {
405 WholeFile,
408 Fragment(String),
411}
412
413#[cfg(feature = "blake3")]
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
415enum DependencyPathState {
416 Missing,
417 File,
418 Directory,
419 Other,
420}
421
422#[derive(Debug, Clone)]
424pub struct MD057ExistingRelativeLinks {
425 base_path: Arc<Mutex<Option<PathBuf>>>,
430 config: MD057Config,
432}
433
434impl Default for MD057ExistingRelativeLinks {
435 fn default() -> Self {
436 Self {
437 base_path: Arc::new(Mutex::new(None)),
438 config: MD057Config::default(),
439 }
440 }
441}
442
443impl MD057ExistingRelativeLinks {
444 pub fn new() -> Self {
446 Self::default()
447 }
448
449 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
451 let path = path.as_ref();
452 let dir_path = if path.is_file() {
453 path.parent().map(std::path::Path::to_path_buf)
454 } else {
455 Some(path.to_path_buf())
456 };
457
458 if let Ok(mut guard) = self.base_path.lock() {
459 *guard = dir_path;
460 }
461 self
462 }
463
464 pub fn from_config_struct(config: MD057Config) -> Self {
465 Self {
466 base_path: Arc::new(Mutex::new(None)),
467 config,
468 }
469 }
470
471 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
475 if Path::new(path_str).is_absolute() {
476 PathBuf::from(path_str)
477 } else {
478 project_root.join(path_str)
479 }
480 }
481
482 #[inline]
485 fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
486 link_resolver::is_non_file_destination(url, flavor)
487 }
488
489 #[inline]
491 fn is_fragment_only_link(&self, url: &str) -> bool {
492 url.starts_with('#')
493 }
494
495 #[inline]
498 fn is_absolute_path(url: &str) -> bool {
499 url.starts_with('/')
500 }
501
502 fn strip_query_and_fragment(url: &str) -> &str {
510 let query_pos = url.find('?');
513 let fragment_pos = url.find('#');
514
515 match (query_pos, fragment_pos) {
516 (Some(q), Some(f)) => {
517 &url[..q.min(f)]
519 }
520 (Some(q), None) => &url[..q],
521 (None, Some(f)) => &url[..f],
522 (None, None) => url,
523 }
524 }
525
526 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
528 base_path.join(link)
529 }
530
531 fn compute_search_paths(
536 &self,
537 flavor: crate::config::MarkdownFlavor,
538 source_file: Option<&Path>,
539 base_path: &Path,
540 project_root: &Path,
541 ) -> Vec<PathBuf> {
542 Self::search_paths_for(&self.config.search_paths, flavor, source_file, base_path, project_root)
543 }
544
545 fn search_paths_for(
549 configured: &[String],
550 flavor: crate::config::MarkdownFlavor,
551 source_file: Option<&Path>,
552 base_path: &Path,
553 project_root: &Path,
554 ) -> Vec<PathBuf> {
555 let mut paths = Vec::new();
556
557 if flavor == crate::config::MarkdownFlavor::Obsidian
559 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
560 && attachment_dir != *base_path
561 {
562 paths.push(attachment_dir);
563 }
564
565 for search_path in configured {
569 let resolved = Self::resolve_against_project_root(search_path, project_root);
570 if resolved != *base_path && !paths.contains(&resolved) {
571 paths.push(resolved);
572 }
573 }
574
575 paths
576 }
577
578 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
583 for destination in ctx.body_link_destinations() {
584 let url = destination.url;
585 if url.is_empty()
586 || (url.starts_with('`') && url.ends_with('`'))
587 || self.is_non_file_destination(url, ctx.flavor)
588 || self.is_fragment_only_link(url)
589 {
590 continue;
591 }
592 index.add_md057_link_target(Md057LinkTarget {
593 target: url.to_string(),
594 origin: LinkOrigin::Body,
595 });
596 }
597
598 for image in ctx.images() {
599 if ctx
600 .line_info(image.line)
601 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
602 || matches!(image.link_type, LinkType::WikiLink { .. })
603 || ctx.is_in_shortcode(image.byte_offset)
604 {
605 continue;
606 }
607 let url = image.url.as_ref();
608 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
609 continue;
610 }
611 index.add_md057_link_target(Md057LinkTarget {
612 target: url.to_string(),
613 origin: LinkOrigin::Body,
614 });
615 }
616
617 for reference in ctx.reference_definitions() {
618 if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
619 continue;
620 }
621 let url = reference.url.as_str();
622 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
623 continue;
624 }
625 index.add_md057_link_target(Md057LinkTarget {
626 target: url.to_string(),
627 origin: LinkOrigin::Body,
628 });
629 }
630
631 for link in frontmatter_values::link_destinations(ctx) {
632 let line = ctx.lines[link.line - 1].content(ctx.content);
633 let url = &line[link.range];
634 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
635 continue;
636 }
637 index.add_md057_link_target(Md057LinkTarget {
638 target: url.to_string(),
639 origin: LinkOrigin::FrontMatter { field: link.field },
640 });
641 }
642 }
643
644 fn resolve_in_search_paths(
647 decoded_path: &str,
648 search_paths: &[PathBuf],
649 policy: Option<&crate::lint_context::LinkTargetPolicy>,
650 ) -> Option<PathBuf> {
651 search_paths
652 .iter()
653 .find_map(|dir| Self::resolve_target(dir, &dir.join(decoded_path), policy))
654 }
655
656 fn resolve_target(
661 anchor: &Path,
662 path: &Path,
663 policy: Option<&crate::lint_context::LinkTargetPolicy>,
664 ) -> Option<PathBuf> {
665 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
666 return Some(supplied);
667 }
668 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
669 return None;
670 }
671 resolve_existing_target(anchor, path)
672 }
673
674 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
675 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
676 format!("Relative link '{url}' target not in the supplied document set")
677 } else {
678 format!("Relative link '{url}' does not exist")
679 }
680 }
681
682 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
688 if !self.config.compact_paths {
689 return None;
690 }
691
692 let path_end = url
694 .find('?')
695 .unwrap_or(url.len())
696 .min(url.find('#').unwrap_or(url.len()));
697 let path_part = &url[..path_end];
698 let suffix = &url[path_end..];
699
700 let decoded_path = url_decode(path_part);
702
703 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
704 }
705
706 fn self_referential_link(
718 &self,
719 url: &str,
720 base_path: &Path,
721 search_paths: &[PathBuf],
722 source_file: Option<&Path>,
723 policy: Option<&crate::lint_context::LinkTargetPolicy>,
724 ) -> Option<SelfReferentialLink> {
725 if !self.config.self_referential_links {
726 return None;
727 }
728 let source_file = source_file?;
729
730 let path_part = Self::strip_query_and_fragment(url);
731 if path_part.is_empty() {
732 return None;
733 }
734 let suffix = &url[path_part.len()..];
735
736 let decoded_path = url_decode(path_part);
737 let resolved = std::iter::once(base_path)
741 .chain(search_paths.iter().map(PathBuf::as_path))
742 .find_map(|dir| {
743 Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
744 })?;
745 if !Self::is_same_file(&resolved, source_file) {
746 return None;
747 }
748
749 match suffix.strip_prefix('#') {
753 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
754 _ => Some(SelfReferentialLink::WholeFile),
755 }
756 }
757
758 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
765 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
766 let label_end = Self::label_end(def)?;
767 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
768 title.saturating_sub(ref_def.byte_offset).min(def.len())
769 });
770 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
771 let start = ref_def.byte_offset + offset;
772 Some(start..start + ref_def.url.len())
773 }
774
775 fn label_end(def: &str) -> Option<usize> {
780 let bytes = def.as_bytes();
781 let mut i = 0;
782 while i < bytes.len() {
783 match bytes[i] {
784 b'\\' => i += 2,
785 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
786 _ => i += 1,
787 }
788 }
789 None
790 }
791
792 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
796 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
797 }
798
799 fn absolute_link_message(
802 &self,
803 url: &str,
804 anchor: &LazyAnchor<'_>,
805 project_root: &Path,
806 policy: Option<&crate::lint_context::LinkTargetPolicy>,
807 ) -> Option<String> {
808 match self.config.absolute_links {
809 AbsoluteLinksOption::Ignore => None,
810 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
811 AbsoluteLinksOption::RelativeToDocs => {
812 Self::validate_absolute_link_via_docs_dir(url, &anchor.base_path, policy)
813 }
814 AbsoluteLinksOption::RelativeToRoots => {
815 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root, policy)
816 }
817 }
818 }
819
820 fn check_front_matter(
827 &self,
828 ctx: &crate::lint_context::LintContext,
829 anchor: &LazyAnchor<'_>,
830 project_root: &Path,
831 warnings: &mut Vec<LintWarning>,
832 ) {
833 if !self.config.check_frontmatter {
834 return;
835 }
836
837 let ignored: HashSet<String> = self
838 .config
839 .ignore_frontmatter_fields
840 .iter()
841 .map(|field| field.to_lowercase())
842 .collect();
843
844 for link in frontmatter_values::link_destinations(ctx) {
845 if link.field_is_in(&ignored) {
846 continue;
847 }
848
849 let line = ctx.lines[link.line - 1].content(ctx.content);
850 let url = &line[link.range.clone()];
851
852 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
855 continue;
856 }
857
858 let column = byte_to_char_count(line, link.range.start);
859 let end_column = column + url.chars().count();
860
861 if Self::is_absolute_path(url) {
862 if let Some(message) = self.absolute_link_message(url, anchor, project_root, ctx.link_target_policy()) {
863 warnings.push(LintWarning {
864 rule_name: Some(self.name().to_string()),
865 line: link.line,
866 column,
867 end_line: link.line,
868 end_column,
869 message,
870 severity: Severity::Warning,
871 fix: None,
872 });
873 }
874 continue;
875 }
876
877 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
878 continue;
879 }
880
881 warnings.push(LintWarning {
882 rule_name: Some(self.name().to_string()),
883 line: link.line,
884 column,
885 end_line: link.line,
886 end_column,
887 message: Self::missing_relative_message(url, ctx.link_target_policy()),
888 severity: Severity::Error,
889 fix: None,
890 });
891 }
892 }
893
894 fn relative_target_exists(
896 url: &str,
897 base_path: &Path,
898 search_paths: &[PathBuf],
899 policy: Option<&crate::lint_context::LinkTargetPolicy>,
900 ) -> bool {
901 Self::resolve_relative(url, base_path, search_paths, policy).is_some()
902 }
903
904 fn resolve_relative(
911 url: &str,
912 base_path: &Path,
913 search_paths: &[PathBuf],
914 policy: Option<&crate::lint_context::LinkTargetPolicy>,
915 ) -> Option<PathBuf> {
916 let decoded_path = link_path_part(url);
917 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
918
919 if let Some(target) = Self::resolve_target(base_path, &resolved_path, policy) {
921 return Some(target);
922 }
923
924 if let Some(source) =
925 html_markdown_sources(&resolved_path).find_map(|source| Self::resolve_target(base_path, &source, policy))
926 {
927 return Some(source);
928 }
929
930 Self::resolve_in_search_paths(&decoded_path, search_paths, policy)
931 }
932
933 fn document_anchor(
936 &self,
937 flavor: crate::config::MarkdownFlavor,
938 source_file: Option<&Path>,
939 explicit_base: Option<PathBuf>,
940 project_root: &Path,
941 ) -> DocumentAnchor {
942 #[cfg(test)]
943 ANCHOR_RESOLUTIONS.with(|count| count.set(count.get() + 1));
944 let self_path = source_file.map(|source_file| {
945 crate::source_paths::canonicalize_source(source_file).unwrap_or_else(|_| source_file.to_path_buf())
946 });
947 let base_path = explicit_base.unwrap_or_else(|| {
948 self_path
949 .as_deref()
950 .and_then(Path::parent)
951 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
952 });
953 let search_paths = self.compute_search_paths(flavor, source_file, &base_path, project_root);
954 DocumentAnchor {
955 self_path,
956 base_path,
957 search_paths,
958 }
959 }
960
961 fn produces_fixes(&self) -> bool {
965 self.config.compact_paths || self.config.self_referential_links
966 }
967
968 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
970 match self_link {
971 SelfReferentialLink::Fragment(fragment) => {
972 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
973 }
974 SelfReferentialLink::WholeFile => {
975 format!("Relative link '{url}' points to the file it is in")
976 }
977 }
978 }
979
980 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
986 if resolved.file_name() != source_file.file_name() {
988 return false;
989 }
990 match (
991 resolved.canonicalize(),
992 crate::source_paths::canonicalize_source(source_file),
993 ) {
994 (Ok(link), Ok(source)) => link == source,
995 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
996 }
997 }
998
999 fn validate_absolute_link_via_docs_dir(
1004 url: &str,
1005 source_path: &Path,
1006 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1007 ) -> Option<String> {
1008 let Some(resolution) = Self::resolve_absolute_via_docs_dir(url, source_path, policy) else {
1009 return Some(format!(
1010 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
1011 ));
1012 };
1013
1014 match resolution {
1015 Resolution::Found(_) => None,
1016 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
1017 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
1018 resolved.display()
1019 )),
1020 Resolution::NotFound { resolved } => Some(format!(
1021 "Absolute link '{url}' resolves to '{}' which does not exist",
1022 resolved.display()
1023 )),
1024 }
1025 }
1026
1027 fn validate_absolute_link_via_roots(
1036 url: &str,
1037 roots: &[String],
1038 project_root: &Path,
1039 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1040 ) -> Option<String> {
1041 if Self::resolve_absolute_via_roots(url, roots, project_root, policy).is_some() {
1042 return None;
1043 }
1044
1045 let msg = if roots.is_empty() {
1046 format!("Absolute link '{url}' was not found under the project root")
1047 } else {
1048 format!("Absolute link '{url}' was not found under any configured root or the project root")
1049 };
1050 Some(msg)
1051 }
1052
1053 fn resolve_absolute_via_docs_dir(
1059 url: &str,
1060 source_path: &Path,
1061 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1062 ) -> Option<Resolution> {
1063 let docs_dir = resolve_docs_dir(source_path)?;
1064 let decoded = Self::prepare_absolute_url(url);
1065 Some(Self::resolve_under_root_with_opts(&docs_dir, &decoded, true, policy))
1066 }
1067
1068 fn resolve_absolute_via_roots(
1074 url: &str,
1075 roots: &[String],
1076 project_root: &Path,
1077 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1078 ) -> Option<PathBuf> {
1079 let decoded = Self::prepare_absolute_url(url);
1080 roots
1081 .iter()
1082 .map(|root| Self::resolve_against_project_root(root, project_root))
1083 .chain(std::iter::once(project_root.to_path_buf()))
1084 .find_map(
1085 |root| match Self::resolve_under_root_with_opts(&root, &decoded, false, policy) {
1086 Resolution::Found(target) => Some(target),
1087 _ => None,
1088 },
1089 )
1090 }
1091
1092 fn prepare_absolute_url(url: &str) -> String {
1095 let relative_url = url.trim_start_matches('/');
1096 let file_path = Self::strip_query_and_fragment(relative_url);
1097 url_decode(file_path)
1098 }
1099
1100 fn resolve_under_root_with_opts(
1133 root_path: &Path,
1134 decoded: &str,
1135 require_index_for_dirs: bool,
1136 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1137 ) -> Resolution {
1138 let resolved = root_path.join(decoded);
1139
1140 if let Some(policy) = policy {
1141 if let Some(resolution) = Self::resolve_supplied_under_root(&resolved, require_index_for_dirs, policy) {
1142 return resolution;
1143 }
1144 if !policy.allow_disk_fallback() {
1145 return Resolution::NotFound { resolved };
1146 }
1147 }
1148
1149 if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1150 if !require_index_for_dirs {
1151 return Resolution::Found(resolved);
1152 }
1153 let index = resolved.join("index.md");
1154 return if exists_exact_case(root_path, &index) {
1155 Resolution::Found(index)
1156 } else {
1157 Resolution::DirectoryWithoutIndex { resolved }
1158 };
1159 }
1160
1161 if let Some(target) = resolve_existing_target(root_path, &resolved) {
1162 return Resolution::Found(target);
1163 }
1164
1165 if let Some(source) = html_markdown_sources(&resolved).find(|source| exists_exact_case(root_path, source)) {
1168 return Resolution::Found(source);
1169 }
1170
1171 Resolution::NotFound { resolved }
1172 }
1173
1174 fn resolve_supplied_under_root(
1180 resolved: &Path,
1181 require_index_for_dirs: bool,
1182 policy: &crate::lint_context::LinkTargetPolicy,
1183 ) -> Option<Resolution> {
1184 let Some(supplied) = policy.resolve_supplied(resolved) else {
1185 return html_markdown_sources(resolved)
1186 .find(|source| policy.contains(source))
1187 .map(Resolution::Found);
1188 };
1189 if policy.contains(&supplied) || !require_index_for_dirs {
1190 return Some(Resolution::Found(supplied));
1191 }
1192 let index = supplied.join("index.md");
1193 if policy.contains(&index) {
1194 Some(Resolution::Found(index))
1195 } else if policy.allow_disk_fallback() {
1196 None
1197 } else {
1198 Some(Resolution::DirectoryWithoutIndex {
1199 resolved: resolved.to_path_buf(),
1200 })
1201 }
1202 }
1203}
1204
1205#[cfg(feature = "blake3")]
1209impl MD057ExistingRelativeLinks {
1210 pub fn cache_dependency_fingerprint(
1216 &self,
1217 source_file: &Path,
1218 flavor: crate::config::MarkdownFlavor,
1219 file_index: &FileIndex,
1220 ) -> String {
1221 let mut hasher = blake3::Hasher::new();
1222 hasher.update(b"rumdl-md057-dependencies-v1");
1223 if file_index.md057_link_targets.is_empty() {
1224 return hasher.finalize().to_hex().to_string();
1225 }
1226
1227 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1228 let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1229 let anchor = LazyCell::new(|| self.document_anchor(flavor, Some(source_file), explicit_base, &project_root));
1233 let ignored_frontmatter_fields: HashSet<String> = self
1234 .config
1235 .ignore_frontmatter_fields
1236 .iter()
1237 .map(|field| field.to_lowercase())
1238 .collect();
1239
1240 for dependency in &file_index.md057_link_targets {
1241 if let LinkOrigin::FrontMatter { field } = &dependency.origin
1242 && (!self.config.check_frontmatter
1243 || field
1244 .as_ref()
1245 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1246 {
1247 continue;
1248 }
1249
1250 let url = dependency.target.as_str();
1251 if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1252 continue;
1253 }
1254
1255 Self::hash_bytes(&mut hasher, url.as_bytes());
1256 if Self::is_absolute_path(url) {
1257 match self.config.absolute_links {
1258 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1259 AbsoluteLinksOption::RelativeToDocs => {
1260 hasher.update(b"docs");
1261 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1262 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1263 } else {
1264 hasher.update(b"no-docs-dir");
1265 }
1266 }
1267 AbsoluteLinksOption::RelativeToRoots => {
1268 hasher.update(b"roots");
1269 let decoded = Self::prepare_absolute_url(url);
1270 let mut found = false;
1271 for root in &self.config.roots {
1272 let root_path = Self::resolve_against_project_root(root, &project_root);
1273 if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1274 found = true;
1275 break;
1276 }
1277 }
1278 if !found {
1279 Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1280 }
1281 }
1282 }
1283 } else {
1284 hasher.update(b"relative");
1285 if self.config.self_referential_links
1286 && Self::observe_self_referential_resolution(
1287 &mut hasher,
1288 url,
1289 &anchor.base_path,
1290 &anchor.search_paths,
1291 anchor.self_path.as_deref().unwrap_or(source_file),
1292 )
1293 {
1294 continue;
1295 }
1296 Self::observe_relative_resolution(&mut hasher, url, &anchor.base_path, &anchor.search_paths);
1297 }
1298 }
1299
1300 hasher.finalize().to_hex().to_string()
1301 }
1302
1303 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1304 hasher.update(&(bytes.len() as u64).to_le_bytes());
1305 hasher.update(bytes);
1306 }
1307
1308 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1309 #[cfg(unix)]
1310 {
1311 use std::os::unix::ffi::OsStrExt;
1312 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1313 }
1314 #[cfg(windows)]
1315 {
1316 use std::os::windows::ffi::OsStrExt;
1317 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1318 Self::hash_bytes(hasher, &encoded);
1319 }
1320 #[cfg(not(any(unix, windows)))]
1321 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1322 }
1323
1324 fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1329 Self::hash_path(hasher, path);
1330 let state = if exists_exact_case(anchor, path) {
1331 match std::fs::metadata(path) {
1332 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1333 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1334 Ok(_) => DependencyPathState::Other,
1335 Err(_) => DependencyPathState::Missing,
1336 }
1337 } else {
1338 DependencyPathState::Missing
1339 };
1340 hasher.update(&[match state {
1341 DependencyPathState::Missing => 0,
1342 DependencyPathState::File => 1,
1343 DependencyPathState::Directory => 2,
1344 DependencyPathState::Other => 3,
1345 }]);
1346 state
1347 }
1348
1349 fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1350 if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1351 return Some(path.to_path_buf());
1352 }
1353 if path.extension().is_none() {
1354 for extension in MARKDOWN_EXTENSIONS {
1355 let candidate = path.with_extension(&extension[1..]);
1356 if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1357 return Some(candidate);
1358 }
1359 }
1360 }
1361 None
1362 }
1363
1364 fn observe_self_referential_resolution(
1365 hasher: &mut blake3::Hasher,
1366 url: &str,
1367 base_path: &Path,
1368 search_paths: &[PathBuf],
1369 source_file: &Path,
1370 ) -> bool {
1371 let decoded = url_decode(Self::strip_query_and_fragment(url));
1372 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1373 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1374 if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1375 let canonical = resolved.canonicalize().unwrap_or(resolved);
1376 hasher.update(b"resolved-identity");
1377 Self::hash_path(hasher, &canonical);
1378 return Self::is_same_file(&canonical, source_file);
1379 }
1380 }
1381 false
1382 }
1383
1384 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1385 let decoded = url_decode(Self::strip_query_and_fragment(url));
1386 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1387 if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1388 return;
1389 }
1390
1391 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1392 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1393 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1394 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1395 Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1396 != DependencyPathState::Missing
1397 })
1398 {
1399 return;
1400 }
1401
1402 for search_path in search_paths {
1403 if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1404 return;
1405 }
1406 }
1407 }
1408
1409 fn observe_absolute_resolution(
1410 hasher: &mut blake3::Hasher,
1411 root: &Path,
1412 url: &str,
1413 require_index_for_dirs: bool,
1414 ) -> bool {
1415 let decoded = Self::prepare_absolute_url(url);
1416 Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1417 }
1418
1419 fn observe_under_root(
1423 hasher: &mut blake3::Hasher,
1424 root: &Path,
1425 decoded: &str,
1426 require_index_for_dirs: bool,
1427 ) -> bool {
1428 let resolved = root.join(decoded);
1429 let resolved_state = Self::observe_path(hasher, root, &resolved);
1430
1431 if resolved_state == DependencyPathState::Directory {
1432 if !require_index_for_dirs {
1433 return true;
1434 }
1435 return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1436 }
1437
1438 if resolved_state != DependencyPathState::Missing {
1439 return true;
1440 }
1441 if resolved.extension().is_none()
1442 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1443 Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1444 != DependencyPathState::Missing
1445 })
1446 {
1447 return true;
1448 }
1449
1450 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1451 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1452 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1453 {
1454 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1455 Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1456 != DependencyPathState::Missing
1457 });
1458 }
1459
1460 false
1461 }
1462}
1463
1464enum Resolution {
1468 Found(PathBuf),
1469 DirectoryWithoutIndex { resolved: PathBuf },
1470 NotFound { resolved: PathBuf },
1471}
1472
1473impl Rule for MD057ExistingRelativeLinks {
1474 fn name(&self) -> &'static str {
1475 "MD057"
1476 }
1477
1478 fn description(&self) -> &'static str {
1479 "Relative links should point to existing files"
1480 }
1481
1482 fn category(&self) -> RuleCategory {
1483 RuleCategory::Link
1484 }
1485
1486 fn skippable_by_category(&self) -> bool {
1487 !self.config.check_frontmatter
1490 }
1491
1492 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1493 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1494 }
1495
1496 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1497 let content = ctx.content;
1498
1499 if content.is_empty() {
1500 return Ok(Vec::new());
1501 }
1502
1503 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1507 if !has_body_links && !self.checks_front_matter_of(ctx) {
1508 return Ok(Vec::new());
1509 }
1510
1511 reset_file_existence_cache();
1513 let _directory_check = DirectoryCheck::begin();
1514
1515 let mut warnings = Vec::new();
1516
1517 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1521
1522 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1526
1527 let source_file = ctx.source_file();
1530 if explicit_base.is_none() && source_file.is_none() {
1531 return Ok(warnings);
1532 }
1533 let anchor: LazyAnchor<'_> = LazyCell::new(Box::new(|| {
1536 self.document_anchor(ctx.flavor, source_file, explicit_base, &project_root)
1537 }));
1538
1539 for destination in ctx.body_link_destinations() {
1544 let url = destination.url;
1545
1546 if url.is_empty() {
1548 continue;
1549 }
1550
1551 if url.starts_with('`') && url.ends_with('`') {
1555 continue;
1556 }
1557
1558 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1560 continue;
1561 }
1562
1563 if Self::is_absolute_path(url) {
1565 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1566 {
1567 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1568 warnings.push(LintWarning {
1569 rule_name: Some(self.name().to_string()),
1570 line,
1571 column,
1572 end_line: line,
1573 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1574 message,
1575 severity: Severity::Warning,
1576 fix: None,
1577 });
1578 }
1579 continue;
1580 }
1581
1582 let full_url = destination.full_url();
1585
1586 if let Some(self_link) = self.self_referential_link(
1591 &full_url,
1592 &anchor.base_path,
1593 &anchor.search_paths,
1594 anchor.self_path.as_deref(),
1595 ctx.link_target_policy(),
1596 ) {
1597 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1598 warnings.push(LintWarning {
1599 rule_name: Some(self.name().to_string()),
1600 line,
1601 column,
1602 end_line: line,
1603 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1604 message: Self::self_referential_message(&full_url, &self_link),
1605 severity: Severity::Warning,
1606 fix: match &self_link {
1607 SelfReferentialLink::Fragment(fragment) => {
1608 Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1609 }
1610 SelfReferentialLink::WholeFile => None,
1611 },
1612 });
1613 continue;
1614 }
1615
1616 if let Some(suggestion) = self.compact_path_suggestion(&full_url, &anchor.base_path) {
1617 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1618 warnings.push(LintWarning {
1619 rule_name: Some(self.name().to_string()),
1620 line,
1621 column,
1622 end_line: line,
1623 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1624 message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1625 severity: Severity::Warning,
1626 fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1627 });
1628 }
1629
1630 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1631 continue;
1632 }
1633
1634 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1636 warnings.push(LintWarning {
1637 rule_name: Some(self.name().to_string()),
1638 line,
1639 column,
1640 end_line: line,
1641 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1642 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1643 severity: Severity::Error,
1644 fix: None,
1645 });
1646 }
1647
1648 for image in ctx.images() {
1650 if ctx
1652 .line_info(image.line)
1653 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1654 {
1655 continue;
1656 }
1657
1658 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1662 continue;
1663 }
1664
1665 if ctx.is_in_shortcode(image.byte_offset) {
1668 continue;
1669 }
1670
1671 let url = image.url.as_ref();
1672
1673 if url.is_empty() {
1675 continue;
1676 }
1677
1678 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1680 continue;
1681 }
1682
1683 if Self::is_absolute_path(url) {
1685 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1686 {
1687 warnings.push(LintWarning {
1688 rule_name: Some(self.name().to_string()),
1689 line: image.line,
1690 column: image.start_col + 1,
1691 end_line: image.line,
1692 end_column: image.start_col + 1 + url.chars().count(),
1693 message,
1694 severity: Severity::Warning,
1695 fix: None,
1696 });
1697 }
1698 continue;
1699 }
1700
1701 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1703 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1706 let fix_byte_start = image.byte_offset + url_offset;
1707 let fix_byte_end = fix_byte_start + url.len();
1708 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1709 });
1710
1711 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1712 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1713 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1716 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1717 });
1718 warnings.push(LintWarning {
1719 rule_name: Some(self.name().to_string()),
1720 line: image.line,
1721 column: url_col,
1722 end_line: image.line,
1723 end_column: url_col + url.chars().count(),
1724 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1725 severity: Severity::Warning,
1726 fix,
1727 });
1728 }
1729
1730 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1731 continue;
1732 }
1733
1734 warnings.push(LintWarning {
1737 rule_name: Some(self.name().to_string()),
1738 line: image.line,
1739 column: image.start_col + 1,
1740 end_line: image.line,
1741 end_column: image.start_col + 1 + url.chars().count(),
1742 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1743 severity: Severity::Error,
1744 fix: None,
1745 });
1746 }
1747
1748 for ref_def in ctx.reference_definitions() {
1750 if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1751 continue;
1752 }
1753 let url = &ref_def.url;
1754
1755 if url.is_empty() {
1757 continue;
1758 }
1759
1760 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1762 continue;
1763 }
1764
1765 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1769 let (line, col) = url_range
1770 .as_ref()
1771 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1772 let end_col = col + url.chars().count();
1773
1774 if Self::is_absolute_path(url) {
1776 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1777 {
1778 warnings.push(LintWarning {
1779 rule_name: Some(self.name().to_string()),
1780 line,
1781 column: col,
1782 end_line: line,
1783 end_column: end_col,
1784 message,
1785 severity: Severity::Warning,
1786 fix: None,
1787 });
1788 }
1789 continue;
1790 }
1791
1792 if let Some(self_link) = self.self_referential_link(
1794 url,
1795 &anchor.base_path,
1796 &anchor.search_paths,
1797 anchor.self_path.as_deref(),
1798 ctx.link_target_policy(),
1799 ) {
1800 warnings.push(LintWarning {
1801 rule_name: Some(self.name().to_string()),
1802 line,
1803 column: col,
1804 end_line: line,
1805 end_column: end_col,
1806 message: Self::self_referential_message(url, &self_link),
1807 severity: Severity::Warning,
1808 fix: match (&self_link, &url_range) {
1809 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1810 Some(Fix::new(range.clone(), fragment.clone()))
1811 }
1812 _ => None,
1813 },
1814 });
1815 continue;
1816 }
1817
1818 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1820 warnings.push(LintWarning {
1821 rule_name: Some(self.name().to_string()),
1822 line,
1823 column: col,
1824 end_line: line,
1825 end_column: end_col,
1826 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1827 severity: Severity::Warning,
1828 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1829 });
1830 }
1831
1832 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1833 continue;
1834 }
1835
1836 warnings.push(LintWarning {
1838 rule_name: Some(self.name().to_string()),
1839 line,
1840 column: col,
1841 end_line: line,
1842 end_column: end_col,
1843 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1844 severity: Severity::Error,
1845 fix: None,
1846 });
1847 }
1848
1849 self.check_front_matter(ctx, &anchor, &project_root, &mut warnings);
1850
1851 Ok(warnings)
1852 }
1853
1854 fn fix_capability(&self) -> FixCapability {
1855 if self.produces_fixes() {
1856 FixCapability::ConditionallyFixable
1857 } else {
1858 FixCapability::Unfixable
1859 }
1860 }
1861
1862 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
1863 if !self.produces_fixes() {
1864 return Ok(ctx.content.to_string());
1865 }
1866
1867 let warnings = self.check(ctx)?;
1868 let warnings =
1869 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
1870 let mut content = ctx.content.to_string();
1871
1872 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
1874 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
1875
1876 let mut last_applied_start: Option<usize> = None;
1882 for fix in fixes {
1883 if let Some(prev_start) = last_applied_start
1884 && fix.range.end > prev_start
1885 {
1886 continue;
1887 }
1888 if fix.range.end <= content.len() {
1889 content.replace_range(fix.range.clone(), &fix.replacement);
1890 last_applied_start = Some(fix.range.start);
1891 }
1892 }
1893
1894 Ok(content)
1895 }
1896
1897 fn as_any(&self) -> &dyn std::any::Any {
1898 self
1899 }
1900
1901 crate::impl_rule_config_sections!(MD057Config);
1902
1903 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
1904 where
1905 Self: Sized,
1906 {
1907 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
1908 Box::new(Self::from_config_struct(rule_config))
1912 }
1913
1914 fn cross_file_scope(&self) -> CrossFileScope {
1915 CrossFileScope::Workspace
1916 }
1917
1918 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
1919 self.contribute_dependency_targets(ctx, index);
1920
1921 let links = extract_cross_file_links(ctx);
1924 for link in links.relative {
1925 index.add_cross_file_link(link);
1926 }
1927 for link in links.root_relative {
1930 index.add_root_relative_link(link);
1931 }
1932 }
1933
1934 fn cross_file_check(
1935 &self,
1936 _file_path: &Path,
1937 _file_index: &FileIndex,
1938 _workspace_index: &crate::workspace_index::WorkspaceIndex,
1939 ) -> LintResult {
1940 Ok(Vec::new())
1950 }
1951}
1952
1953fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
1958 let from_components: Vec<_> = from_dir.components().collect();
1959 let to_components: Vec<_> = to_path.components().collect();
1960
1961 let common_len = from_components
1963 .iter()
1964 .zip(to_components.iter())
1965 .take_while(|(a, b)| a == b)
1966 .count();
1967
1968 let mut result = PathBuf::new();
1969
1970 for _ in common_len..from_components.len() {
1972 result.push("..");
1973 }
1974
1975 for component in &to_components[common_len..] {
1977 result.push(component);
1978 }
1979
1980 result
1981}
1982
1983fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
1989 let link_path = Path::new(raw_link_path);
1990
1991 let has_traversal = link_path
1993 .components()
1994 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
1995
1996 if !has_traversal {
1997 return None;
1998 }
1999
2000 let combined = source_dir.join(link_path);
2002 let normalized_target = normalize_relative_path(&combined);
2003
2004 let normalized_source = normalize_relative_path(source_dir);
2006 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2007
2008 if shortest != link_path {
2010 let compact = shortest.to_string_lossy().to_string();
2011 if compact.is_empty() {
2013 return None;
2014 }
2015 Some(compact.replace('\\', "/"))
2017 } else {
2018 None
2019 }
2020}
2021
2022#[cfg(test)]
2023mod tests {
2024 use super::*;
2025 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2026 use std::fs::File;
2027 use std::io::Write;
2028 use tempfile::tempdir;
2029
2030 #[test]
2031 fn test_strip_query_and_fragment() {
2032 assert_eq!(
2034 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2035 "file.png"
2036 );
2037 assert_eq!(
2038 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2039 "file.png"
2040 );
2041 assert_eq!(
2042 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2043 "file.png"
2044 );
2045
2046 assert_eq!(
2048 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2049 "file.md"
2050 );
2051 assert_eq!(
2052 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2053 "file.md"
2054 );
2055
2056 assert_eq!(
2058 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2059 "file.md"
2060 );
2061
2062 assert_eq!(
2064 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2065 "file.png"
2066 );
2067
2068 assert_eq!(
2070 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2071 "path/to/image.png"
2072 );
2073 assert_eq!(
2074 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2075 "path/to/image.png"
2076 );
2077
2078 assert_eq!(
2080 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2081 "file.md"
2082 );
2083 }
2084
2085 #[test]
2086 fn test_url_decode() {
2087 assert_eq!(url_decode("penguin%20with%20space.jpg"), "penguin with space.jpg");
2089
2090 assert_eq!(url_decode("assets/my%20file%20name.png"), "assets/my file name.png");
2092
2093 assert_eq!(url_decode("hello%20world%21.md"), "hello world!.md");
2095
2096 assert_eq!(url_decode("%2f%2e%2e"), "/..");
2098
2099 assert_eq!(url_decode("%2F%2E%2E"), "/..");
2101
2102 assert_eq!(url_decode("%2f%2E%2e"), "/..");
2104
2105 assert_eq!(url_decode("normal-file.md"), "normal-file.md");
2107
2108 assert_eq!(url_decode("file%2.txt"), "file%2.txt");
2110
2111 assert_eq!(url_decode("file%"), "file%");
2113
2114 assert_eq!(url_decode("file%GG.txt"), "file%GG.txt");
2116
2117 assert_eq!(url_decode("file+name.txt"), "file+name.txt");
2119
2120 assert_eq!(url_decode(""), "");
2122
2123 assert_eq!(url_decode("caf%C3%A9.md"), "café.md");
2125
2126 assert_eq!(url_decode("%20%20%20"), " ");
2128
2129 assert_eq!(url_decode("path%2Fto%2Ffile.md"), "path/to/file.md");
2131
2132 assert_eq!(url_decode("hello%20world/foo%20bar.md"), "hello world/foo bar.md");
2134
2135 assert_eq!(url_decode("file%5B1%5D.md"), "file[1].md");
2137
2138 assert_eq!(url_decode("100%pure.md"), "100%pure.md");
2140 }
2141
2142 #[test]
2143 fn test_url_encoded_filenames() {
2144 let temp_dir = tempdir().unwrap();
2146 let base_path = temp_dir.path();
2147
2148 let file_with_spaces = base_path.join("penguin with space.jpg");
2150 File::create(&file_with_spaces)
2151 .unwrap()
2152 .write_all(b"image data")
2153 .unwrap();
2154
2155 let subdir = base_path.join("my images");
2157 std::fs::create_dir(&subdir).unwrap();
2158 let nested_file = subdir.join("photo 1.png");
2159 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2160
2161 let content = r#"
2163# Test Document with URL-Encoded Links
2164
2165
2166
2167
2168"#;
2169
2170 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2171
2172 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2173 let result = rule.check(&ctx).unwrap();
2174
2175 assert_eq!(
2177 result.len(),
2178 1,
2179 "Should only warn about missing%20file.jpg. Got: {result:?}"
2180 );
2181 assert!(
2182 result[0].message.contains("missing%20file.jpg"),
2183 "Warning should mention the URL-encoded filename"
2184 );
2185 }
2186
2187 #[test]
2188 fn test_external_urls() {
2189 assert!(is_external_url("https://example.com"));
2191 assert!(is_external_url("http://example.com"));
2192 assert!(is_external_url("ftp://example.com"));
2193 assert!(is_external_url("www.example.com"));
2194 assert!(is_external_url("example.com"));
2195
2196 assert!(is_external_url("file:///path/to/file"));
2198 assert!(is_external_url("smb://server/share"));
2199 assert!(is_external_url("macappstores://apps.apple.com/"));
2200 assert!(is_external_url("mailto:user@example.com"));
2201 assert!(is_external_url("tel:+1234567890"));
2202 assert!(is_external_url("data:text/plain;base64,SGVsbG8="));
2203 assert!(is_external_url("javascript:void(0)"));
2204 assert!(is_external_url("ssh://git@github.com/repo"));
2205 assert!(is_external_url("git://github.com/repo.git"));
2206
2207 assert!(is_external_url("user@example.com"));
2210 assert!(is_external_url("steering@kubernetes.io"));
2211 assert!(is_external_url("john.doe+filter@company.co.uk"));
2212 assert!(is_external_url("user_name@sub.domain.com"));
2213 assert!(is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2214
2215 assert!(is_external_url("{{URL}}")); assert!(is_external_url("{{#URL}}")); assert!(is_external_url("{{> partial}}")); assert!(is_external_url("{{ variable }}")); assert!(is_external_url("{{% include %}}")); assert!(is_external_url("{{")); assert!(!is_external_url("/api/v1/users"));
2226 assert!(!is_external_url("/blog/2024/release.html"));
2227 assert!(!is_external_url("/react/hooks/use-state.html"));
2228 assert!(!is_external_url("/pkg/runtime"));
2229 assert!(!is_external_url("/doc/go1compat"));
2230 assert!(!is_external_url("/index.html"));
2231 assert!(!is_external_url("/assets/logo.png"));
2232
2233 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2235 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2236 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2237 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2238 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2239
2240 assert!(is_external_url("~/assets/image.png"));
2243 assert!(is_external_url("~/components/Button.vue"));
2244 assert!(is_external_url("~assets/logo.svg")); assert!(is_external_url("@/components/Header.vue"));
2248 assert!(is_external_url("@images/photo.jpg"));
2249 assert!(is_external_url("@assets/styles.css"));
2250
2251 assert!(!is_external_url("./relative/path.md"));
2253 assert!(!is_external_url("relative/path.md"));
2254 assert!(!is_external_url("../parent/path.md"));
2255 }
2256
2257 #[test]
2258 fn test_dot_com_only_skips_bare_domains() {
2259 assert!(is_external_url("example.com"));
2261 assert!(is_external_url("sub.example.com"));
2262
2263 assert!(!is_external_url("../../vendor.com"));
2267 assert!(!is_external_url("./vendor.com"));
2268 assert!(!is_external_url("docs/vendor.com"));
2269 }
2270
2271 #[test]
2272 fn test_framework_path_aliases() {
2273 let temp_dir = tempdir().unwrap();
2275 let base_path = temp_dir.path();
2276
2277 let content = r#"
2279# Framework Path Aliases
2280
2281
2282
2283
2284
2285[Link](@/pages/about.md)
2286
2287This is a [real missing link](missing.md) that should be flagged.
2288"#;
2289
2290 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2291
2292 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2293 let result = rule.check(&ctx).unwrap();
2294
2295 assert_eq!(
2297 result.len(),
2298 1,
2299 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2300 );
2301 assert!(
2302 result[0].message.contains("missing.md"),
2303 "Warning should be for missing.md"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_url_decode_security_path_traversal() {
2309 let temp_dir = tempdir().unwrap();
2312 let base_path = temp_dir.path();
2313
2314 let file_in_base = base_path.join("safe.md");
2316 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2317
2318 let content = r#"
2323[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2324[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2325[Safe link](safe.md)
2326"#;
2327
2328 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2329
2330 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2331 let result = rule.check(&ctx).unwrap();
2332
2333 assert_eq!(
2336 result.len(),
2337 2,
2338 "Should have warnings for traversal attempts. Got: {result:?}"
2339 );
2340 }
2341
2342 #[test]
2343 fn test_url_encoded_utf8_filenames() {
2344 let temp_dir = tempdir().unwrap();
2346 let base_path = temp_dir.path();
2347
2348 let cafe_file = base_path.join("café.md");
2350 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2351
2352 let content = r#"
2353[Café link](caf%C3%A9.md)
2354[Missing unicode](r%C3%A9sum%C3%A9.md)
2355"#;
2356
2357 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2358
2359 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2360 let result = rule.check(&ctx).unwrap();
2361
2362 assert_eq!(
2364 result.len(),
2365 1,
2366 "Should only warn about missing résumé.md. Got: {result:?}"
2367 );
2368 assert!(
2369 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2370 "Warning should mention the URL-encoded filename"
2371 );
2372 }
2373
2374 #[test]
2375 fn test_url_encoded_emoji_filenames() {
2376 let temp_dir = tempdir().unwrap();
2379 let base_path = temp_dir.path();
2380
2381 let emoji_dir = base_path.join("👤 Personal");
2383 std::fs::create_dir(&emoji_dir).unwrap();
2384
2385 let file_path = emoji_dir.join("TV Shows.md");
2387 File::create(&file_path)
2388 .unwrap()
2389 .write_all(b"# TV Shows\n\nContent here.")
2390 .unwrap();
2391
2392 let content = r#"
2395# Test Document
2396
2397[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2398[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2399"#;
2400
2401 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2402
2403 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2404 let result = rule.check(&ctx).unwrap();
2405
2406 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2408 assert!(
2409 result[0].message.contains("Missing.md"),
2410 "Warning should be for Missing.md, got: {}",
2411 result[0].message
2412 );
2413 }
2414
2415 #[test]
2416 fn test_no_warnings_without_base_path() {
2417 let rule = MD057ExistingRelativeLinks::new();
2418 let content = "[Link](missing.md)";
2419
2420 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2421 let result = rule.check(&ctx).unwrap();
2422 assert!(result.is_empty(), "Should have no warnings without base path");
2423 }
2424
2425 #[test]
2426 fn test_existing_and_missing_links() {
2427 let temp_dir = tempdir().unwrap();
2429 let base_path = temp_dir.path();
2430
2431 let exists_path = base_path.join("exists.md");
2433 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2434
2435 assert!(exists_path.exists(), "exists.md should exist for this test");
2437
2438 let content = r#"
2440# Test Document
2441
2442[Valid Link](exists.md)
2443[Invalid Link](missing.md)
2444[External Link](https://example.com)
2445[Media Link](image.jpg)
2446 "#;
2447
2448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2450
2451 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2453 let result = rule.check(&ctx).unwrap();
2454
2455 assert_eq!(result.len(), 2);
2457 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2458 assert!(messages.iter().any(|m| m.contains("missing.md")));
2459 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2460 }
2461
2462 #[test]
2463 fn test_angle_bracket_links() {
2464 let temp_dir = tempdir().unwrap();
2466 let base_path = temp_dir.path();
2467
2468 let exists_path = base_path.join("exists.md");
2470 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2471
2472 let content = r#"
2474# Test Document
2475
2476[Valid Link](<exists.md>)
2477[Invalid Link](<missing.md>)
2478[External Link](<https://example.com>)
2479 "#;
2480
2481 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2483
2484 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2485 let result = rule.check(&ctx).unwrap();
2486
2487 assert_eq!(result.len(), 1, "Should have exactly one warning");
2489 assert!(
2490 result[0].message.contains("missing.md"),
2491 "Warning should mention missing.md"
2492 );
2493 }
2494
2495 #[test]
2496 fn test_angle_bracket_links_with_parens() {
2497 let temp_dir = tempdir().unwrap();
2499 let base_path = temp_dir.path();
2500
2501 let app_dir = base_path.join("app");
2503 std::fs::create_dir(&app_dir).unwrap();
2504 let upload_dir = app_dir.join("(upload)");
2505 std::fs::create_dir(&upload_dir).unwrap();
2506 let page_file = upload_dir.join("page.tsx");
2507 File::create(&page_file)
2508 .unwrap()
2509 .write_all(b"export default function Page() {}")
2510 .unwrap();
2511
2512 let content = r#"
2514# Test Document with Paths Containing Parens
2515
2516[Upload Page](<app/(upload)/page.tsx>)
2517[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2518[Missing](<app/(missing)/file.md>)
2519"#;
2520
2521 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2522
2523 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2524 let result = rule.check(&ctx).unwrap();
2525
2526 assert_eq!(
2528 result.len(),
2529 1,
2530 "Should have exactly one warning for missing file. Got: {result:?}"
2531 );
2532 assert!(
2533 result[0].message.contains("app/(missing)/file.md"),
2534 "Warning should mention app/(missing)/file.md"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_balanced_parentheses_in_link_paths() {
2540 let temp_dir = tempdir().unwrap();
2545 let base_path = temp_dir.path();
2546
2547 let paren_file = base_path.join("file(inner).md");
2549 File::create(&paren_file)
2550 .unwrap()
2551 .write_all(b"# file(inner).md\n")
2552 .unwrap();
2553
2554 let folder = base_path.join("folder(inner)");
2556 std::fs::create_dir(&folder).unwrap();
2557 File::create(folder.join("file.md"))
2558 .unwrap()
2559 .write_all(b"# folder(inner)/file.md\n")
2560 .unwrap();
2561
2562 let content = r#"
2563# Test cases
2564
2565[File with parenthesis exists](file(inner).md)
2566[Folder with parenthesis exists](folder(inner)/file.md)
2567[Missing with parenthesis](missing(inner).md)
2568"#;
2569
2570 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2571
2572 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2573 let result = rule.check(&ctx).unwrap();
2574
2575 assert_eq!(
2578 result.len(),
2579 1,
2580 "Expected exactly one warning (the missing file). Got: {result:?}"
2581 );
2582 assert!(
2583 result[0].message.contains("missing(inner).md"),
2584 "Warning should name the full path `missing(inner).md`, got: {}",
2585 result[0].message
2586 );
2587 }
2588
2589 #[test]
2590 fn test_all_file_types_checked() {
2591 let temp_dir = tempdir().unwrap();
2593 let base_path = temp_dir.path();
2594
2595 let content = r#"
2597[Image Link](image.jpg)
2598[Video Link](video.mp4)
2599[Markdown Link](document.md)
2600[PDF Link](file.pdf)
2601"#;
2602
2603 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2604
2605 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2606 let result = rule.check(&ctx).unwrap();
2607
2608 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2610 }
2611
2612 #[test]
2613 fn test_code_span_detection() {
2614 let rule = MD057ExistingRelativeLinks::new();
2615
2616 let temp_dir = tempdir().unwrap();
2618 let base_path = temp_dir.path();
2619
2620 let rule = rule.with_path(base_path);
2621
2622 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2624
2625 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2626 let result = rule.check(&ctx).unwrap();
2627
2628 assert_eq!(result.len(), 1, "Should only flag the real link");
2630 assert!(result[0].message.contains("nonexistent.md"));
2631 }
2632
2633 #[test]
2634 fn test_inline_code_spans() {
2635 let temp_dir = tempdir().unwrap();
2637 let base_path = temp_dir.path();
2638
2639 let content = r#"
2641# Test Document
2642
2643This is a normal link: [Link](missing.md)
2644
2645This is a code span with a link: `[Link](another-missing.md)`
2646
2647Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2648
2649 "#;
2650
2651 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2653
2654 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2656 let result = rule.check(&ctx).unwrap();
2657
2658 assert_eq!(result.len(), 1, "Should have exactly one warning");
2660 assert!(
2661 result[0].message.contains("missing.md"),
2662 "Warning should be for missing.md"
2663 );
2664 assert!(
2665 !result.iter().any(|w| w.message.contains("another-missing.md")),
2666 "Should not warn about link in code span"
2667 );
2668 assert!(
2669 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2670 "Should not warn about link in inline code"
2671 );
2672 }
2673
2674 #[test]
2675 fn test_extensionless_link_resolution() {
2676 let temp_dir = tempdir().unwrap();
2678 let base_path = temp_dir.path();
2679
2680 let page_path = base_path.join("page.md");
2682 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2683
2684 let content = r#"
2686# Test Document
2687
2688[Link without extension](page)
2689[Link with extension](page.md)
2690[Missing link](nonexistent)
2691"#;
2692
2693 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2694
2695 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2696 let result = rule.check(&ctx).unwrap();
2697
2698 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2701 assert!(
2702 result[0].message.contains("nonexistent"),
2703 "Warning should be for 'nonexistent' not 'page'"
2704 );
2705 }
2706
2707 #[test]
2709 fn test_cross_file_scope() {
2710 let rule = MD057ExistingRelativeLinks::new();
2711 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2712 }
2713
2714 #[test]
2715 fn test_contribute_to_index_extracts_markdown_links() {
2716 let rule = MD057ExistingRelativeLinks::new();
2717 let content = r#"
2718# Document
2719
2720[Link to docs](./docs/guide.md)
2721[Link with fragment](./other.md#section)
2722[External link](https://example.com)
2723[Image link](image.png)
2724[Media file](video.mp4)
2725"#;
2726
2727 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2728 let mut index = FileIndex::new();
2729 rule.contribute_to_index(&ctx, &mut index);
2730
2731 assert_eq!(index.cross_file_links.len(), 2);
2733
2734 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2736 assert_eq!(index.cross_file_links[0].fragment, "");
2737
2738 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2740 assert_eq!(index.cross_file_links[1].fragment, "section");
2741 }
2742
2743 #[test]
2744 fn test_contribute_to_index_skips_external_and_anchors() {
2745 let rule = MD057ExistingRelativeLinks::new();
2746 let content = r#"
2747# Document
2748
2749[External](https://example.com)
2750[Another external](http://example.org)
2751[Fragment only](#section)
2752[FTP link](ftp://files.example.com)
2753[Mail link](mailto:test@example.com)
2754[WWW link](www.example.com)
2755"#;
2756
2757 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2758 let mut index = FileIndex::new();
2759 rule.contribute_to_index(&ctx, &mut index);
2760
2761 assert_eq!(index.cross_file_links.len(), 0);
2763 }
2764
2765 #[test]
2766 fn test_cross_file_check_valid_link() {
2767 use crate::workspace_index::WorkspaceIndex;
2768
2769 let rule = MD057ExistingRelativeLinks::new();
2770
2771 let mut workspace_index = WorkspaceIndex::new();
2773 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2774
2775 let mut file_index = FileIndex::new();
2777 file_index.add_cross_file_link(CrossFileLinkIndex {
2778 target_path: "guide.md".to_string(),
2779 fragment: "".to_string(),
2780 line: 5,
2781 column: 1,
2782 origin: LinkOrigin::Body,
2783 });
2784
2785 let warnings = rule
2787 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2788 .unwrap();
2789
2790 assert!(warnings.is_empty());
2792 }
2793
2794 #[test]
2795 fn test_cross_file_check_missing_link() {
2796 use crate::workspace_index::WorkspaceIndex;
2799
2800 let rule = MD057ExistingRelativeLinks::new();
2801 let workspace_index = WorkspaceIndex::new();
2802
2803 let mut file_index = FileIndex::new();
2804 file_index.add_cross_file_link(CrossFileLinkIndex {
2805 target_path: "missing.md".to_string(),
2806 fragment: "".to_string(),
2807 line: 5,
2808 column: 1,
2809 origin: LinkOrigin::Body,
2810 });
2811
2812 let warnings = rule
2813 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2814 .unwrap();
2815
2816 assert!(
2818 warnings.is_empty(),
2819 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2820 );
2821 }
2822
2823 #[test]
2824 fn test_cross_file_check_parent_path() {
2825 use crate::workspace_index::WorkspaceIndex;
2826
2827 let rule = MD057ExistingRelativeLinks::new();
2828
2829 let mut workspace_index = WorkspaceIndex::new();
2831 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2832
2833 let mut file_index = FileIndex::new();
2835 file_index.add_cross_file_link(CrossFileLinkIndex {
2836 target_path: "../readme.md".to_string(),
2837 fragment: "".to_string(),
2838 line: 5,
2839 column: 1,
2840 origin: LinkOrigin::Body,
2841 });
2842
2843 let warnings = rule
2845 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2846 .unwrap();
2847
2848 assert!(warnings.is_empty());
2850 }
2851
2852 #[test]
2853 fn test_cross_file_check_html_link_with_md_source() {
2854 use crate::workspace_index::WorkspaceIndex;
2857
2858 let rule = MD057ExistingRelativeLinks::new();
2859
2860 let mut workspace_index = WorkspaceIndex::new();
2862 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2863
2864 let mut file_index = FileIndex::new();
2866 file_index.add_cross_file_link(CrossFileLinkIndex {
2867 target_path: "guide.html".to_string(),
2868 fragment: "section".to_string(),
2869 line: 10,
2870 column: 5,
2871 origin: LinkOrigin::Body,
2872 });
2873
2874 let warnings = rule
2876 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2877 .unwrap();
2878
2879 assert!(
2881 warnings.is_empty(),
2882 "Expected no warnings for .html link with .md source, got: {warnings:?}"
2883 );
2884 }
2885
2886 #[test]
2887 fn test_cross_file_check_html_link_without_source() {
2888 use crate::workspace_index::WorkspaceIndex;
2892
2893 let rule = MD057ExistingRelativeLinks::new();
2894 let workspace_index = WorkspaceIndex::new();
2895
2896 let mut file_index = FileIndex::new();
2897 file_index.add_cross_file_link(CrossFileLinkIndex {
2898 target_path: "missing.html".to_string(),
2899 fragment: "".to_string(),
2900 line: 10,
2901 column: 5,
2902 origin: LinkOrigin::Body,
2903 });
2904
2905 let warnings = rule
2906 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2907 .unwrap();
2908
2909 assert!(
2911 warnings.is_empty(),
2912 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2913 );
2914 }
2915
2916 #[test]
2917 fn test_normalize_path_function() {
2918 assert_eq!(
2920 normalize_relative_path(Path::new("docs/guide.md")),
2921 PathBuf::from("docs/guide.md")
2922 );
2923
2924 assert_eq!(
2926 normalize_relative_path(Path::new("./docs/guide.md")),
2927 PathBuf::from("docs/guide.md")
2928 );
2929
2930 assert_eq!(
2932 normalize_relative_path(Path::new("docs/sub/../guide.md")),
2933 PathBuf::from("docs/guide.md")
2934 );
2935
2936 assert_eq!(
2938 normalize_relative_path(Path::new("a/b/c/../../d.md")),
2939 PathBuf::from("a/d.md")
2940 );
2941 }
2942
2943 #[test]
2944 fn test_html_link_with_md_source() {
2945 let temp_dir = tempdir().unwrap();
2947 let base_path = temp_dir.path();
2948
2949 let md_file = base_path.join("guide.md");
2951 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
2952
2953 let content = r#"
2954[Read the guide](guide.html)
2955[Also here](getting-started.html)
2956"#;
2957
2958 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2959 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2960 let result = rule.check(&ctx).unwrap();
2961
2962 assert_eq!(
2964 result.len(),
2965 1,
2966 "Should only warn about missing source. Got: {result:?}"
2967 );
2968 assert!(result[0].message.contains("getting-started.html"));
2969 }
2970
2971 #[test]
2972 fn test_htm_link_with_md_source() {
2973 let temp_dir = tempdir().unwrap();
2975 let base_path = temp_dir.path();
2976
2977 let md_file = base_path.join("page.md");
2978 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
2979
2980 let content = "[Page](page.htm)";
2981
2982 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2983 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2984 let result = rule.check(&ctx).unwrap();
2985
2986 assert!(
2987 result.is_empty(),
2988 "Should not warn when .md source exists for .htm link"
2989 );
2990 }
2991
2992 #[test]
2993 fn test_html_link_finds_various_markdown_extensions() {
2994 let temp_dir = tempdir().unwrap();
2996 let base_path = temp_dir.path();
2997
2998 File::create(base_path.join("doc.md")).unwrap();
2999 File::create(base_path.join("tutorial.mdx")).unwrap();
3000 File::create(base_path.join("guide.markdown")).unwrap();
3001
3002 let content = r#"
3003[Doc](doc.html)
3004[Tutorial](tutorial.html)
3005[Guide](guide.html)
3006"#;
3007
3008 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3009 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3010 let result = rule.check(&ctx).unwrap();
3011
3012 assert!(
3013 result.is_empty(),
3014 "Should find all markdown variants as source files. Got: {result:?}"
3015 );
3016 }
3017
3018 #[test]
3019 fn test_html_link_in_subdirectory() {
3020 let temp_dir = tempdir().unwrap();
3022 let base_path = temp_dir.path();
3023
3024 let docs_dir = base_path.join("docs");
3025 std::fs::create_dir(&docs_dir).unwrap();
3026 File::create(docs_dir.join("guide.md"))
3027 .unwrap()
3028 .write_all(b"# Guide")
3029 .unwrap();
3030
3031 let content = "[Guide](docs/guide.html)";
3032
3033 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3034 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3035 let result = rule.check(&ctx).unwrap();
3036
3037 assert!(result.is_empty(), "Should find markdown source in subdirectory");
3038 }
3039
3040 #[test]
3041 fn test_absolute_path_skipped_in_check() {
3042 let temp_dir = tempdir().unwrap();
3045 let base_path = temp_dir.path();
3046
3047 let content = r#"
3048# Test Document
3049
3050[Go Runtime](/pkg/runtime)
3051[Go Runtime with Fragment](/pkg/runtime#section)
3052[API Docs](/api/v1/users)
3053[Blog Post](/blog/2024/release.html)
3054[React Hook](/react/hooks/use-state.html)
3055"#;
3056
3057 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3058 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3059 let result = rule.check(&ctx).unwrap();
3060
3061 assert!(
3063 result.is_empty(),
3064 "Absolute paths should be skipped. Got warnings: {result:?}"
3065 );
3066 }
3067
3068 #[test]
3069 fn test_absolute_path_skipped_in_cross_file_check() {
3070 use crate::workspace_index::WorkspaceIndex;
3072
3073 let rule = MD057ExistingRelativeLinks::new();
3074
3075 let workspace_index = WorkspaceIndex::new();
3077
3078 let mut file_index = FileIndex::new();
3080 file_index.add_cross_file_link(CrossFileLinkIndex {
3081 target_path: "/pkg/runtime.md".to_string(),
3082 fragment: "".to_string(),
3083 line: 5,
3084 column: 1,
3085 origin: LinkOrigin::Body,
3086 });
3087 file_index.add_cross_file_link(CrossFileLinkIndex {
3088 target_path: "/api/v1/users.md".to_string(),
3089 fragment: "section".to_string(),
3090 line: 10,
3091 column: 1,
3092 origin: LinkOrigin::Body,
3093 });
3094
3095 let warnings = rule
3097 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3098 .unwrap();
3099
3100 assert!(
3102 warnings.is_empty(),
3103 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3104 );
3105 }
3106
3107 #[test]
3108 fn test_protocol_relative_url_not_skipped() {
3109 let temp_dir = tempdir().unwrap();
3112 let base_path = temp_dir.path();
3113
3114 let content = r#"
3115# Test Document
3116
3117[External](//example.com/page)
3118[Another](//cdn.example.com/asset.js)
3119"#;
3120
3121 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3122 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3123 let result = rule.check(&ctx).unwrap();
3124
3125 assert!(
3127 result.is_empty(),
3128 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3129 );
3130 }
3131
3132 #[test]
3133 fn test_email_addresses_skipped() {
3134 let temp_dir = tempdir().unwrap();
3137 let base_path = temp_dir.path();
3138
3139 let content = r#"
3140# Test Document
3141
3142[Contact](user@example.com)
3143[Steering](steering@kubernetes.io)
3144[Support](john.doe+filter@company.co.uk)
3145[User](user_name@sub.domain.com)
3146"#;
3147
3148 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3149 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3150 let result = rule.check(&ctx).unwrap();
3151
3152 assert!(
3154 result.is_empty(),
3155 "Email addresses should be skipped. Got warnings: {result:?}"
3156 );
3157 }
3158
3159 #[test]
3160 fn test_email_addresses_vs_file_paths() {
3161 let temp_dir = tempdir().unwrap();
3164 let base_path = temp_dir.path();
3165
3166 let content = r#"
3167# Test Document
3168
3169[Email](user@example.com) <!-- Should be skipped (email) -->
3170[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3171[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3172"#;
3173
3174 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3175 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3176 let result = rule.check(&ctx).unwrap();
3177
3178 assert!(
3180 result.is_empty(),
3181 "All email addresses should be skipped. Got: {result:?}"
3182 );
3183 }
3184
3185 #[test]
3186 fn test_diagnostic_position_accuracy() {
3187 let temp_dir = tempdir().unwrap();
3189 let base_path = temp_dir.path();
3190
3191 let content = "prefix [text](missing.md) suffix";
3194 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3198 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3199 let result = rule.check(&ctx).unwrap();
3200
3201 assert_eq!(result.len(), 1, "Should have exactly one warning");
3202 assert_eq!(result[0].line, 1, "Should be on line 1");
3203 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3204 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3205 }
3206
3207 #[test]
3208 fn test_diagnostic_position_non_ascii_link() {
3209 let temp_dir = tempdir().unwrap();
3212 let base_path = temp_dir.path();
3213
3214 let content = "ä½ å¥½ä½ å¥½[ä½ å¥½](not-exist.md) bar";
3218
3219 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3220 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3221 let result = rule.check(&ctx).unwrap();
3222
3223 assert_eq!(result.len(), 1, "Should have exactly one warning");
3224 assert_eq!(result[0].line, 1, "Should be on line 1");
3225 assert_eq!(
3226 result[0].column, 10,
3227 "Column must be a character offset, not a byte offset"
3228 );
3229 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3230 }
3231
3232 #[test]
3233 fn test_diagnostic_position_angle_brackets() {
3234 let temp_dir = tempdir().unwrap();
3236 let base_path = temp_dir.path();
3237
3238 let content = "[link](<missing.md>)";
3241 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3244 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3245 let result = rule.check(&ctx).unwrap();
3246
3247 assert_eq!(result.len(), 1, "Should have exactly one warning");
3248 assert_eq!(result[0].line, 1, "Should be on line 1");
3249 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3250 }
3251
3252 #[test]
3253 fn test_diagnostic_position_multiline() {
3254 let temp_dir = tempdir().unwrap();
3256 let base_path = temp_dir.path();
3257
3258 let content = r#"# Title
3259Some text on line 2
3260[link on line 3](missing1.md)
3261More text
3262[link on line 5](missing2.md)"#;
3263
3264 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3265 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3266 let result = rule.check(&ctx).unwrap();
3267
3268 assert_eq!(result.len(), 2, "Should have two warnings");
3269
3270 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3272 assert!(result[0].message.contains("missing1.md"));
3273
3274 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3276 assert!(result[1].message.contains("missing2.md"));
3277 }
3278
3279 #[test]
3280 fn test_diagnostic_position_with_spaces() {
3281 let temp_dir = tempdir().unwrap();
3283 let base_path = temp_dir.path();
3284
3285 let content = "[link]( missing.md )";
3286 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3291 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3292 let result = rule.check(&ctx).unwrap();
3293
3294 assert_eq!(result.len(), 1, "Should have exactly one warning");
3295 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3297 }
3298
3299 #[test]
3300 fn test_diagnostic_position_image() {
3301 let temp_dir = tempdir().unwrap();
3303 let base_path = temp_dir.path();
3304
3305 let content = "";
3306
3307 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3308 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3309 let result = rule.check(&ctx).unwrap();
3310
3311 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3312 assert_eq!(result[0].line, 1);
3313 assert!(result[0].column > 0, "Should have valid column position");
3315 assert!(result[0].message.contains("missing.jpg"));
3316 }
3317
3318 #[test]
3319 fn test_diagnostic_position_non_ascii_image() {
3320 let temp_dir = tempdir().unwrap();
3322 let base_path = temp_dir.path();
3323
3324 let content = "ä½ å¥½ä½ å¥½";
3327
3328 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 for image");
3333 assert_eq!(result[0].line, 1, "Should be on line 1");
3334 assert_eq!(
3335 result[0].column, 5,
3336 "Column must be a character offset, not a byte offset"
3337 );
3338 assert!(result[0].message.contains("not-exist.png"));
3339 }
3340
3341 #[test]
3342 fn test_diagnostic_position_non_ascii_reference_def() {
3343 let temp_dir = tempdir().unwrap();
3347 let base_path = temp_dir.path();
3348
3349 let content = "[ä½ å¥½]: not-exist.md";
3352
3353 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3355 let result = rule.check(&ctx).unwrap();
3356
3357 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3358 assert_eq!(result[0].line, 1, "Should be on line 1");
3359 assert_eq!(
3360 result[0].column, 7,
3361 "Column must be a character offset, not a byte offset"
3362 );
3363 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3364 }
3365
3366 #[test]
3367 fn test_wikilinks_skipped() {
3368 let temp_dir = tempdir().unwrap();
3371 let base_path = temp_dir.path();
3372
3373 let content = r#"# Test Document
3374
3375[[Microsoft#Windows OS]]
3376[[SomePage]]
3377[[Page With Spaces]]
3378[[path/to/page#section]]
3379[[page|Display Text]]
3380
3381This is a [real missing link](missing.md) that should be flagged.
3382"#;
3383
3384 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3385 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3386 let result = rule.check(&ctx).unwrap();
3387
3388 assert_eq!(
3390 result.len(),
3391 1,
3392 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3393 );
3394 assert!(
3395 result[0].message.contains("missing.md"),
3396 "Warning should be for missing.md, not wikilinks"
3397 );
3398 }
3399
3400 #[test]
3401 fn test_wiki_embeds_skipped() {
3402 let temp_dir = tempdir().unwrap();
3406 let base_path = temp_dir.path();
3407
3408 let content = r#"# Test Document
3409
3410![[diagram.png]]
3411![[subfolder/diagram.png]]
3412![[diagram.png|300]]
3413![[Some Note]]
3414
3415This is a [real missing link](missing.md) that should be flagged.
3416"#;
3417
3418 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3419 for flavor in [
3420 crate::config::MarkdownFlavor::Obsidian,
3421 crate::config::MarkdownFlavor::Standard,
3422 ] {
3423 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3424 let result = rule.check(&ctx).unwrap();
3425
3426 assert_eq!(
3427 result.len(),
3428 1,
3429 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3430 );
3431 assert!(result[0].message.contains("missing.md"));
3432 }
3433 }
3434
3435 #[test]
3436 fn test_wikilinks_not_added_to_index() {
3437 let temp_dir = tempdir().unwrap();
3439 let base_path = temp_dir.path();
3440
3441 let content = r#"# Test Document
3442
3443[[Microsoft#Windows OS]]
3444[[SomePage#section]]
3445[Regular Link](other.md)
3446"#;
3447
3448 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3449 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3450
3451 let mut file_index = FileIndex::new();
3452 rule.contribute_to_index(&ctx, &mut file_index);
3453
3454 let cross_file_links = &file_index.cross_file_links;
3457 assert_eq!(
3458 cross_file_links.len(),
3459 1,
3460 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3461 );
3462 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3463 }
3464
3465 #[test]
3466 fn test_reference_definition_missing_file() {
3467 let temp_dir = tempdir().unwrap();
3469 let base_path = temp_dir.path();
3470
3471 let content = r#"# Test Document
3472
3473[test]: ./missing.md
3474[example]: ./nonexistent.html
3475
3476Use [test] and [example] here.
3477"#;
3478
3479 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3480 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3481 let result = rule.check(&ctx).unwrap();
3482
3483 assert_eq!(
3485 result.len(),
3486 2,
3487 "Should have warnings for missing reference definition targets. Got: {result:?}"
3488 );
3489 assert!(
3490 result.iter().any(|w| w.message.contains("missing.md")),
3491 "Should warn about missing.md"
3492 );
3493 assert!(
3494 result.iter().any(|w| w.message.contains("nonexistent.html")),
3495 "Should warn about nonexistent.html"
3496 );
3497 }
3498
3499 #[test]
3500 fn test_reference_definition_existing_file() {
3501 let temp_dir = tempdir().unwrap();
3503 let base_path = temp_dir.path();
3504
3505 let exists_path = base_path.join("exists.md");
3507 File::create(&exists_path)
3508 .unwrap()
3509 .write_all(b"# Existing file")
3510 .unwrap();
3511
3512 let content = r#"# Test Document
3513
3514[test]: ./exists.md
3515
3516Use [test] here.
3517"#;
3518
3519 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3520 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3521 let result = rule.check(&ctx).unwrap();
3522
3523 assert!(
3525 result.is_empty(),
3526 "Should not warn about existing file. Got: {result:?}"
3527 );
3528 }
3529
3530 #[test]
3531 fn test_reference_definition_external_url_skipped() {
3532 let temp_dir = tempdir().unwrap();
3534 let base_path = temp_dir.path();
3535
3536 let content = r#"# Test Document
3537
3538[google]: https://google.com
3539[example]: http://example.org
3540[mail]: mailto:test@example.com
3541[ftp]: ftp://files.example.com
3542[local]: ./missing.md
3543
3544Use [google], [example], [mail], [ftp], [local] here.
3545"#;
3546
3547 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3548 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3549 let result = rule.check(&ctx).unwrap();
3550
3551 assert_eq!(
3553 result.len(),
3554 1,
3555 "Should only warn about local missing file. Got: {result:?}"
3556 );
3557 assert!(
3558 result[0].message.contains("missing.md"),
3559 "Warning should be for missing.md"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_reference_definition_fragment_only_skipped() {
3565 let temp_dir = tempdir().unwrap();
3567 let base_path = temp_dir.path();
3568
3569 let content = r#"# Test Document
3570
3571[section]: #my-section
3572
3573Use [section] here.
3574"#;
3575
3576 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3577 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3578 let result = rule.check(&ctx).unwrap();
3579
3580 assert!(
3582 result.is_empty(),
3583 "Should not warn about fragment-only reference. Got: {result:?}"
3584 );
3585 }
3586
3587 #[test]
3588 fn test_reference_definition_column_position() {
3589 let temp_dir = tempdir().unwrap();
3591 let base_path = temp_dir.path();
3592
3593 let content = "[ref]: ./missing.md";
3596 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3600 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3601 let result = rule.check(&ctx).unwrap();
3602
3603 assert_eq!(result.len(), 1, "Should have exactly one warning");
3604 assert_eq!(result[0].line, 1, "Should be on line 1");
3605 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3606 }
3607
3608 #[test]
3609 fn test_reference_definition_html_with_md_source() {
3610 let temp_dir = tempdir().unwrap();
3612 let base_path = temp_dir.path();
3613
3614 let md_file = base_path.join("guide.md");
3616 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3617
3618 let content = r#"# Test Document
3619
3620[guide]: ./guide.html
3621[missing]: ./missing.html
3622
3623Use [guide] and [missing] here.
3624"#;
3625
3626 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3627 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3628 let result = rule.check(&ctx).unwrap();
3629
3630 assert_eq!(
3632 result.len(),
3633 1,
3634 "Should only warn about missing source. Got: {result:?}"
3635 );
3636 assert!(result[0].message.contains("missing.html"));
3637 }
3638
3639 #[test]
3640 fn test_reference_definition_url_encoded() {
3641 let temp_dir = tempdir().unwrap();
3643 let base_path = temp_dir.path();
3644
3645 let file_with_spaces = base_path.join("file with spaces.md");
3647 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3648
3649 let content = r#"# Test Document
3650
3651[spaces]: ./file%20with%20spaces.md
3652[missing]: ./missing%20file.md
3653
3654Use [spaces] and [missing] here.
3655"#;
3656
3657 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3658 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3659 let result = rule.check(&ctx).unwrap();
3660
3661 assert_eq!(
3663 result.len(),
3664 1,
3665 "Should only warn about missing URL-encoded file. Got: {result:?}"
3666 );
3667 assert!(result[0].message.contains("missing%20file.md"));
3668 }
3669
3670 #[test]
3671 fn test_inline_and_reference_both_checked() {
3672 let temp_dir = tempdir().unwrap();
3674 let base_path = temp_dir.path();
3675
3676 let content = r#"# Test Document
3677
3678[inline link](./inline-missing.md)
3679[ref]: ./ref-missing.md
3680
3681Use [ref] here.
3682"#;
3683
3684 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3685 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3686 let result = rule.check(&ctx).unwrap();
3687
3688 assert_eq!(
3690 result.len(),
3691 2,
3692 "Should warn about both inline and reference links. Got: {result:?}"
3693 );
3694 assert!(
3695 result.iter().any(|w| w.message.contains("inline-missing.md")),
3696 "Should warn about inline-missing.md"
3697 );
3698 assert!(
3699 result.iter().any(|w| w.message.contains("ref-missing.md")),
3700 "Should warn about ref-missing.md"
3701 );
3702 }
3703
3704 #[test]
3705 fn test_footnote_definitions_not_flagged() {
3706 let rule = MD057ExistingRelativeLinks::default();
3709
3710 let content = r#"# Title
3711
3712A footnote[^1].
3713
3714[^1]: [link](https://www.google.com).
3715"#;
3716
3717 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3718 let result = rule.check(&ctx).unwrap();
3719
3720 assert!(
3721 result.is_empty(),
3722 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3723 );
3724 }
3725
3726 #[test]
3727 fn test_footnote_with_relative_link_inside() {
3728 let rule = MD057ExistingRelativeLinks::default();
3731
3732 let content = r#"# Title
3733
3734See the footnote[^1].
3735
3736[^1]: Check out [this file](./existing.md) for more info.
3737[^2]: Also see [missing](./does-not-exist.md).
3738"#;
3739
3740 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3741 let result = rule.check(&ctx).unwrap();
3742
3743 for warning in &result {
3748 assert!(
3749 !warning.message.contains("[this file]"),
3750 "Footnote content should not be treated as URL: {warning:?}"
3751 );
3752 assert!(
3753 !warning.message.contains("[missing]"),
3754 "Footnote content should not be treated as URL: {warning:?}"
3755 );
3756 }
3757 }
3758
3759 #[test]
3760 fn test_mixed_footnotes_and_reference_definitions() {
3761 let temp_dir = tempdir().unwrap();
3763 let base_path = temp_dir.path();
3764
3765 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3766
3767 let content = r#"# Title
3768
3769A footnote[^1] and a [ref link][myref].
3770
3771[^1]: This is a footnote with [link](https://example.com).
3772
3773[myref]: ./missing-file.md "This should be checked"
3774"#;
3775
3776 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3777 let result = rule.check(&ctx).unwrap();
3778
3779 assert_eq!(
3781 result.len(),
3782 1,
3783 "Should only warn about the regular reference definition. Got: {result:?}"
3784 );
3785 assert!(
3786 result[0].message.contains("missing-file.md"),
3787 "Should warn about missing-file.md in reference definition"
3788 );
3789 }
3790
3791 #[test]
3792 fn test_absolute_links_ignore_by_default() {
3793 let temp_dir = tempdir().unwrap();
3795 let base_path = temp_dir.path();
3796
3797 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3798
3799 let content = r#"# Links
3800
3801[API docs](/api/v1/users)
3802[Blog post](/blog/2024/release.html)
3803
3804
3805[ref]: /docs/reference.md
3806"#;
3807
3808 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3809 let result = rule.check(&ctx).unwrap();
3810
3811 assert!(
3813 result.is_empty(),
3814 "Absolute links should be ignored by default. Got: {result:?}"
3815 );
3816 }
3817
3818 #[test]
3819 fn test_absolute_links_warn_config() {
3820 let temp_dir = tempdir().unwrap();
3822 let base_path = temp_dir.path();
3823
3824 let config = MD057Config {
3825 absolute_links: AbsoluteLinksOption::Warn,
3826 ..Default::default()
3827 };
3828 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3829
3830 let content = r#"# Links
3831
3832[API docs](/api/v1/users)
3833[Blog post](/blog/2024/release.html)
3834"#;
3835
3836 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3837 let result = rule.check(&ctx).unwrap();
3838
3839 assert_eq!(
3841 result.len(),
3842 2,
3843 "Should warn about both absolute links. Got: {result:?}"
3844 );
3845 assert!(
3846 result[0].message.contains("cannot be validated locally"),
3847 "Warning should explain why: {}",
3848 result[0].message
3849 );
3850 assert!(
3851 result[0].message.contains("/api/v1/users"),
3852 "Warning should include the link path"
3853 );
3854 }
3855
3856 #[test]
3857 fn test_absolute_links_warn_images() {
3858 let temp_dir = tempdir().unwrap();
3860 let base_path = temp_dir.path();
3861
3862 let config = MD057Config {
3863 absolute_links: AbsoluteLinksOption::Warn,
3864 ..Default::default()
3865 };
3866 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3867
3868 let content = r#"# Images
3869
3870
3871"#;
3872
3873 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3874 let result = rule.check(&ctx).unwrap();
3875
3876 assert_eq!(
3877 result.len(),
3878 1,
3879 "Should warn about absolute image path. Got: {result:?}"
3880 );
3881 assert!(
3882 result[0].message.contains("/assets/logo.png"),
3883 "Warning should include the image path"
3884 );
3885 }
3886
3887 #[test]
3888 fn test_absolute_links_warn_reference_definitions() {
3889 let temp_dir = tempdir().unwrap();
3891 let base_path = temp_dir.path();
3892
3893 let config = MD057Config {
3894 absolute_links: AbsoluteLinksOption::Warn,
3895 ..Default::default()
3896 };
3897 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3898
3899 let content = r#"# Reference
3900
3901See the [docs][ref].
3902
3903[ref]: /docs/reference.md
3904"#;
3905
3906 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3907 let result = rule.check(&ctx).unwrap();
3908
3909 assert_eq!(
3910 result.len(),
3911 1,
3912 "Should warn about absolute reference definition. Got: {result:?}"
3913 );
3914 assert!(
3915 result[0].message.contains("/docs/reference.md"),
3916 "Warning should include the reference path"
3917 );
3918 }
3919
3920 #[test]
3921 fn test_search_paths_inline_link() {
3922 let temp_dir = tempdir().unwrap();
3923 let base_path = temp_dir.path();
3924
3925 let assets_dir = base_path.join("assets");
3927 std::fs::create_dir_all(&assets_dir).unwrap();
3928 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
3929
3930 let config = MD057Config {
3931 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3932 ..Default::default()
3933 };
3934 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3935
3936 let content = "# Test\n\n[Photo](photo.png)\n";
3937 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3938 let result = rule.check(&ctx).unwrap();
3939
3940 assert!(
3941 result.is_empty(),
3942 "Should find photo.png via search-paths. Got: {result:?}"
3943 );
3944 }
3945
3946 #[test]
3947 fn test_search_paths_image() {
3948 let temp_dir = tempdir().unwrap();
3949 let base_path = temp_dir.path();
3950
3951 let assets_dir = base_path.join("attachments");
3952 std::fs::create_dir_all(&assets_dir).unwrap();
3953 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
3954
3955 let config = MD057Config {
3956 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3957 ..Default::default()
3958 };
3959 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3960
3961 let content = "# Test\n\n\n";
3962 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3963 let result = rule.check(&ctx).unwrap();
3964
3965 assert!(
3966 result.is_empty(),
3967 "Should find diagram.svg via search-paths. Got: {result:?}"
3968 );
3969 }
3970
3971 #[test]
3972 fn test_search_paths_reference_definition() {
3973 let temp_dir = tempdir().unwrap();
3974 let base_path = temp_dir.path();
3975
3976 let assets_dir = base_path.join("images");
3977 std::fs::create_dir_all(&assets_dir).unwrap();
3978 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
3979
3980 let config = MD057Config {
3981 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
3982 ..Default::default()
3983 };
3984 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3985
3986 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
3987 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3988 let result = rule.check(&ctx).unwrap();
3989
3990 assert!(
3991 result.is_empty(),
3992 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
3993 );
3994 }
3995
3996 #[test]
3997 fn test_search_paths_still_warns_when_truly_missing() {
3998 let temp_dir = tempdir().unwrap();
3999 let base_path = temp_dir.path();
4000
4001 let assets_dir = base_path.join("assets");
4002 std::fs::create_dir_all(&assets_dir).unwrap();
4003
4004 let config = MD057Config {
4005 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4006 ..Default::default()
4007 };
4008 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4009
4010 let content = "# Test\n\n\n";
4011 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4012 let result = rule.check(&ctx).unwrap();
4013
4014 assert_eq!(
4015 result.len(),
4016 1,
4017 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4018 );
4019 }
4020
4021 #[test]
4022 fn test_search_paths_nonexistent_directory() {
4023 let temp_dir = tempdir().unwrap();
4024 let base_path = temp_dir.path();
4025
4026 let config = MD057Config {
4027 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4028 ..Default::default()
4029 };
4030 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4031
4032 let content = "# Test\n\n\n";
4033 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4034 let result = rule.check(&ctx).unwrap();
4035
4036 assert_eq!(
4037 result.len(),
4038 1,
4039 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4040 );
4041 }
4042
4043 #[test]
4044 fn test_obsidian_attachment_folder_named() {
4045 let temp_dir = tempdir().unwrap();
4046 let vault = temp_dir.path().join("vault");
4047 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4048 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4049 std::fs::create_dir_all(vault.join("notes")).unwrap();
4050
4051 std::fs::write(
4052 vault.join(".obsidian/app.json"),
4053 r#"{"attachmentFolderPath": "Attachments"}"#,
4054 )
4055 .unwrap();
4056 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4057
4058 let notes_dir = vault.join("notes");
4059 let source_file = notes_dir.join("test.md");
4060 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
4061
4062 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4063
4064 let content = "# Test\n\n\n";
4065 let ctx =
4066 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4067 let result = rule.check(&ctx).unwrap();
4068
4069 assert!(
4070 result.is_empty(),
4071 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4072 );
4073 }
4074
4075 #[test]
4076 fn test_obsidian_attachment_same_folder_as_file() {
4077 let temp_dir = tempdir().unwrap();
4078 let vault = temp_dir.path().join("vault-rf");
4079 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4080 std::fs::create_dir_all(vault.join("notes")).unwrap();
4081
4082 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4083
4084 let notes_dir = vault.join("notes");
4086 let source_file = notes_dir.join("test.md");
4087 std::fs::write(&source_file, "placeholder").unwrap();
4088 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4089
4090 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4091
4092 let content = "# Test\n\n\n";
4093 let ctx =
4094 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4095 let result = rule.check(&ctx).unwrap();
4096
4097 assert!(
4098 result.is_empty(),
4099 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4100 );
4101 }
4102
4103 #[test]
4104 fn test_obsidian_not_triggered_without_obsidian_flavor() {
4105 let temp_dir = tempdir().unwrap();
4106 let vault = temp_dir.path().join("vault-nf");
4107 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4108 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4109 std::fs::create_dir_all(vault.join("notes")).unwrap();
4110
4111 std::fs::write(
4112 vault.join(".obsidian/app.json"),
4113 r#"{"attachmentFolderPath": "Attachments"}"#,
4114 )
4115 .unwrap();
4116 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4117
4118 let notes_dir = vault.join("notes");
4119 let source_file = notes_dir.join("test.md");
4120 std::fs::write(&source_file, "placeholder").unwrap();
4121
4122 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4123
4124 let content = "# Test\n\n\n";
4125 let ctx =
4127 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4128 let result = rule.check(&ctx).unwrap();
4129
4130 assert_eq!(
4131 result.len(),
4132 1,
4133 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4134 );
4135 }
4136
4137 #[test]
4138 fn test_search_paths_combined_with_obsidian() {
4139 let temp_dir = tempdir().unwrap();
4140 let vault = temp_dir.path().join("vault-combo");
4141 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4142 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4143 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4144 std::fs::create_dir_all(vault.join("notes")).unwrap();
4145
4146 std::fs::write(
4147 vault.join(".obsidian/app.json"),
4148 r#"{"attachmentFolderPath": "Attachments"}"#,
4149 )
4150 .unwrap();
4151 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4152 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4153
4154 let notes_dir = vault.join("notes");
4155 let source_file = notes_dir.join("test.md");
4156 std::fs::write(&source_file, "placeholder").unwrap();
4157
4158 let extra_assets_dir = vault.join("extra-assets");
4159 let config = MD057Config {
4160 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4161 ..Default::default()
4162 };
4163 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4164
4165 let content = "# Test\n\n\n\n\n";
4167 let ctx =
4168 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4169 let result = rule.check(&ctx).unwrap();
4170
4171 assert!(
4172 result.is_empty(),
4173 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4174 );
4175 }
4176
4177 #[test]
4178 fn test_obsidian_attachment_subfolder_under_file() {
4179 let temp_dir = tempdir().unwrap();
4180 let vault = temp_dir.path().join("vault-sub");
4181 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4182 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4183
4184 std::fs::write(
4185 vault.join(".obsidian/app.json"),
4186 r#"{"attachmentFolderPath": "./assets"}"#,
4187 )
4188 .unwrap();
4189 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4190
4191 let notes_dir = vault.join("notes");
4192 let source_file = notes_dir.join("test.md");
4193 std::fs::write(&source_file, "placeholder").unwrap();
4194
4195 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4196
4197 let content = "# Test\n\n\n";
4198 let ctx =
4199 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4200 let result = rule.check(&ctx).unwrap();
4201
4202 assert!(
4203 result.is_empty(),
4204 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4205 );
4206 }
4207
4208 #[test]
4209 fn test_obsidian_attachment_vault_root() {
4210 let temp_dir = tempdir().unwrap();
4211 let vault = temp_dir.path().join("vault-root");
4212 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4213 std::fs::create_dir_all(vault.join("notes")).unwrap();
4214
4215 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4217 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4218
4219 let notes_dir = vault.join("notes");
4220 let source_file = notes_dir.join("test.md");
4221 std::fs::write(&source_file, "placeholder").unwrap();
4222
4223 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4224
4225 let content = "# Test\n\n\n";
4226 let ctx =
4227 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4228 let result = rule.check(&ctx).unwrap();
4229
4230 assert!(
4231 result.is_empty(),
4232 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4233 );
4234 }
4235
4236 #[test]
4237 fn test_search_paths_multiple_directories() {
4238 let temp_dir = tempdir().unwrap();
4239 let base_path = temp_dir.path();
4240
4241 let dir_a = base_path.join("dir-a");
4242 let dir_b = base_path.join("dir-b");
4243 std::fs::create_dir_all(&dir_a).unwrap();
4244 std::fs::create_dir_all(&dir_b).unwrap();
4245 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4246 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4247
4248 let config = MD057Config {
4249 search_paths: vec![
4250 dir_a.to_string_lossy().into_owned(),
4251 dir_b.to_string_lossy().into_owned(),
4252 ],
4253 ..Default::default()
4254 };
4255 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4256
4257 let content = "# Test\n\n\n\n\n";
4258 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4259 let result = rule.check(&ctx).unwrap();
4260
4261 assert!(
4262 result.is_empty(),
4263 "Should find files across multiple search paths. Got: {result:?}"
4264 );
4265 }
4266
4267 #[test]
4276 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4277 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4278
4279 let temp_dir = tempdir().unwrap();
4280 let base_path = temp_dir.path();
4281
4282 let file_path = base_path.join("README.md");
4283 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4284 std::fs::write(&file_path, content).unwrap();
4285
4286 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4287
4288 let ctx = crate::lint_context::LintContext::new(
4289 content,
4290 crate::config::MarkdownFlavor::Standard,
4291 Some(file_path.clone()),
4292 );
4293 let per_file = rule.check(&ctx).unwrap();
4294 assert_eq!(
4295 per_file.len(),
4296 1,
4297 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4298 );
4299
4300 let mut file_index = FileIndex::default();
4301 file_index.cross_file_links.push(CrossFileLinkIndex {
4302 target_path: "missing-guide.md".to_string(),
4303 fragment: String::new(),
4304 line: 3,
4305 column: 1,
4306 origin: LinkOrigin::Body,
4307 });
4308
4309 let result = rule
4310 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4311 .unwrap();
4312
4313 assert!(
4314 result.is_empty(),
4315 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4316 );
4317 }
4318
4319 #[test]
4320 fn test_check_clears_stale_cache() {
4321 let temp_dir = tempdir().unwrap();
4324 let base_path = temp_dir.path();
4325
4326 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4327
4328 let phantom_path = base_path.join("phantom.md");
4330 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(phantom_path.clone(), true));
4331
4332 let content = "[phantom](phantom.md)\n";
4333 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4334 let warnings = rule.check(&ctx).unwrap();
4335
4336 assert_eq!(
4338 warnings.len(),
4339 1,
4340 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4341 );
4342 assert!(warnings[0].message.contains("phantom.md"));
4343 }
4344
4345 #[test]
4346 fn test_check_does_not_carry_over_cache_between_runs() {
4347 let temp_dir = tempdir().unwrap();
4349 let base_path = temp_dir.path();
4350
4351 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4352
4353 let content = "[missing](nonexistent.md)\n";
4354 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4355
4356 let warnings_1 = rule.check(&ctx).unwrap();
4358 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4359
4360 let nonexistent_path = base_path.join("nonexistent.md");
4362 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(nonexistent_path.clone(), true));
4363
4364 let warnings_2 = rule.check(&ctx).unwrap();
4366 assert_eq!(
4367 warnings_2.len(),
4368 1,
4369 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4370 );
4371 }
4372
4373 fn anchor_resolutions_for(dir: &Path, name: &str, content: &str, config: MD057Config) -> (Vec<LintWarning>, usize) {
4376 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4377 let warnings = self_referential_links_tests::check_as_file(dir, name, content, config);
4378 (warnings, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4379 }
4380
4381 #[test]
4382 fn a_document_without_relative_destinations_never_resolves_its_anchor() {
4383 let temp_dir = tempdir().unwrap();
4384 let content = "[web](https://example.com/a.md) [top](#top) [abs](/docs/a.md)\n\
4385  \n\n\
4386 [ref]: https://example.com/r.md\n[aref]: /r.md\n";
4387 let (warnings, resolutions) =
4388 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4389 assert!(warnings.is_empty(), "{warnings:?}");
4390 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4391 }
4392
4393 #[test]
4394 fn a_document_resolves_its_anchor_once_for_every_relative_destination() {
4395 let temp_dir = tempdir().unwrap();
4396 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4397 let content = "[a](there.md) [b](missing.md) \n\n[d]: there.md\n";
4398 let (warnings, resolutions) =
4399 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4400 assert_eq!(warnings.len(), 2, "{warnings:?}");
4401 assert_eq!(resolutions, 1);
4402 }
4403
4404 #[test]
4405 fn an_absolute_link_resolved_from_the_docs_dir_resolves_the_anchor() {
4406 let temp_dir = tempdir().unwrap();
4407 std::fs::write(temp_dir.path().join("mkdocs.yml"), "site_name: x\n").unwrap();
4408 std::fs::create_dir(temp_dir.path().join("docs")).unwrap();
4409 let config = MD057Config {
4410 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4411 ..Default::default()
4412 };
4413 let (warnings, resolutions) =
4414 anchor_resolutions_for(&temp_dir.path().join("docs"), "doc.md", "[abs](/missing.md)\n", config);
4415 assert_eq!(warnings.len(), 1, "{warnings:?}");
4416 assert!(warnings[0].message.contains("does not exist"), "{warnings:?}");
4417 assert_eq!(resolutions, 1);
4418 }
4419
4420 #[cfg(feature = "blake3")]
4423 fn fingerprint_anchor_resolutions(dir: &Path, content: &str) -> (String, usize) {
4424 let source_file = dir.join("doc.md");
4425 std::fs::write(&source_file, content).unwrap();
4426 let rule = MD057ExistingRelativeLinks::default();
4427 let ctx = crate::lint_context::LintContext::new(
4428 content,
4429 crate::config::MarkdownFlavor::Standard,
4430 Some(source_file.clone()),
4431 );
4432 let mut file_index = FileIndex::new();
4433 rule.contribute_to_index(&ctx, &mut file_index);
4434 reset_file_existence_cache();
4436 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4437 let fingerprint =
4438 rule.cache_dependency_fingerprint(&source_file, crate::config::MarkdownFlavor::Standard, &file_index);
4439 (fingerprint, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4440 }
4441
4442 #[cfg(feature = "blake3")]
4443 #[test]
4444 fn a_fingerprint_over_absolute_destinations_never_resolves_the_anchor() {
4445 let temp_dir = tempdir().unwrap();
4446 let (_, resolutions) =
4447 fingerprint_anchor_resolutions(temp_dir.path(), "[a](/docs/a.md) \n\n[c]: /r.md\n");
4448 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4449 }
4450
4451 #[cfg(feature = "blake3")]
4452 #[test]
4453 fn a_fingerprint_over_relative_destinations_resolves_the_anchor_once_and_tracks_them() {
4454 let temp_dir = tempdir().unwrap();
4455 let content = "[a](there.md) [b](missing.md)\n\n[c]: there.md\n";
4456 let (missing, resolutions) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4457 assert_eq!(resolutions, 1);
4458 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4459 let (present, _) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4460 assert_ne!(missing, present, "creating a link target must change the fingerprint");
4461 }
4462
4463 #[test]
4469 fn test_no_duplicate_warnings_for_broken_relative_link() {
4470 use crate::workspace_index::WorkspaceIndex;
4471
4472 let temp_dir = tempdir().unwrap();
4473 let base_path = temp_dir.path();
4474
4475 let source_file = base_path.join("index.md");
4477 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4478
4479 let content = "[broken](does/not/exist.md)\n";
4480
4481 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4482
4483 let ctx = crate::lint_context::LintContext::new(
4485 content,
4486 crate::config::MarkdownFlavor::Standard,
4487 Some(source_file.clone()),
4488 );
4489 let check_warnings = rule.check(&ctx).unwrap();
4490
4491 let mut file_index = FileIndex::new();
4493 rule.contribute_to_index(&ctx, &mut file_index);
4494 let workspace_index = WorkspaceIndex::new();
4495 let cross_warnings = rule
4496 .cross_file_check(&source_file, &file_index, &workspace_index)
4497 .unwrap();
4498
4499 let total = check_warnings.len() + cross_warnings.len();
4500 assert_eq!(
4501 total, 1,
4502 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4503 check={check_warnings:?}, cross={cross_warnings:?}"
4504 );
4505 }
4506
4507 #[test]
4512 fn test_absolute_dir_link_accepted_relative_to_roots() {
4513 let temp_dir = tempdir().unwrap();
4514 let root = temp_dir.path();
4515
4516 let dir_d = root.join("d");
4518 std::fs::create_dir_all(&dir_d).unwrap();
4519 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4520
4521 let content = "\
4524[absolute dir](/d)\n\
4525[relative dir](d)\n\
4526[absolute file](/d/foo.md)\n\
4527[relative file](d/foo.md)\n";
4528
4529 let config = MD057Config {
4530 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4531 roots: vec![],
4532 ..Default::default()
4533 };
4534 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4535
4536 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4537 let result = rule.check(&ctx).unwrap();
4538
4539 assert!(
4540 result.is_empty(),
4541 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4542 );
4543 }
4544
4545 #[test]
4551 fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4552 let temp_dir = tempdir().unwrap();
4553 let root = temp_dir.path();
4554
4555 let dir_d = root.join("d");
4557 std::fs::create_dir_all(&dir_d).unwrap();
4558 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4559
4560 let content = "\
4561[no slash](/d)\n\
4562[trailing slash](/d/)\n\
4563[trailing slash and fragment](/d/#intro)\n\
4564[relative](d/)\n";
4565
4566 let config = MD057Config {
4567 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4568 roots: vec![],
4569 ..Default::default()
4570 };
4571 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4572
4573 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4574 let result = rule.check(&ctx).unwrap();
4575
4576 assert!(
4577 result.is_empty(),
4578 "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4579 );
4580 }
4581
4582 #[test]
4585 fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4586 let temp_dir = tempdir().unwrap();
4587 let root = temp_dir.path();
4588
4589 let content = "[gone](/nodir/)\n";
4590
4591 let config = MD057Config {
4592 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4593 roots: vec![],
4594 ..Default::default()
4595 };
4596 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4597
4598 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4599 let result = rule.check(&ctx).unwrap();
4600
4601 assert_eq!(
4602 result.len(),
4603 1,
4604 "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4605 );
4606 }
4607
4608 #[test]
4612 fn test_docs_dir_variant_still_enforces_index_md() {
4613 let temp_dir = tempdir().unwrap();
4614 let root = temp_dir.path();
4615
4616 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4618
4619 let docs_dir = root.join("docs");
4621 std::fs::create_dir_all(&docs_dir).unwrap();
4622 let section_dir = docs_dir.join("section");
4623 std::fs::create_dir_all(§ion_dir).unwrap();
4624 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4625
4626 let source_file = docs_dir.join("index.md");
4628 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4629
4630 let config = MD057Config {
4631 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4632 ..Default::default()
4633 };
4634 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4635
4636 let content = "[sec](/section)\n";
4637 let ctx = crate::lint_context::LintContext::new(
4638 content,
4639 crate::config::MarkdownFlavor::Standard,
4640 Some(source_file.clone()),
4641 );
4642 let result = rule.check(&ctx).unwrap();
4643
4644 assert_eq!(
4646 result.len(),
4647 1,
4648 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4649 );
4650 assert!(
4651 result[0].message.contains("index.md") || result[0].message.contains("section"),
4652 "Message should mention the directory or missing index.md: {}",
4653 result[0].message
4654 );
4655 }
4656
4657 #[test]
4662 fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4663 let temp_dir = tempdir().unwrap();
4664 let root = temp_dir.path();
4665 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4666
4667 let docs_dir = root.join("docs");
4669 let guide_dir = docs_dir.join("guide");
4670 std::fs::create_dir_all(&guide_dir).unwrap();
4671 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4672
4673 let source_file = docs_dir.join("t.md");
4674 let content = "\
4675[no slash](/guide)\n\
4676[trailing slash](/guide/)\n\
4677[trailing slash and fragment](/guide/#intro)\n";
4678 std::fs::write(&source_file, content).unwrap();
4679
4680 let config = MD057Config {
4681 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4682 ..Default::default()
4683 };
4684 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4685 let ctx = crate::lint_context::LintContext::new(
4686 content,
4687 crate::config::MarkdownFlavor::Standard,
4688 Some(source_file.clone()),
4689 );
4690 let result = rule.check(&ctx).unwrap();
4691
4692 assert_eq!(
4693 result.len(),
4694 3,
4695 "Every directory link must be routed through the index.md check. Got: {result:?}"
4696 );
4697 for warning in &result {
4698 assert!(
4699 warning.message.contains("which has no index.md"),
4700 "The message must name the reason, not report the directory as missing: {}",
4701 warning.message
4702 );
4703 }
4704 }
4705}
4706
4707#[cfg(test)]
4708mod self_referential_links_tests {
4709 use super::*;
4710 use tempfile::tempdir;
4711
4712 pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4714 let source_file = dir.join(name);
4715 std::fs::write(&source_file, content).unwrap();
4716 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4717 let ctx =
4718 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4719 rule.check(&ctx).unwrap()
4720 }
4721
4722 fn enabled() -> MD057Config {
4723 MD057Config {
4724 self_referential_links: true,
4725 ..Default::default()
4726 }
4727 }
4728
4729 #[test]
4730 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4731 let temp_dir = tempdir().unwrap();
4732 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4733 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4734
4735 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4736 assert_eq!(
4737 result[0].message,
4738 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4739 );
4740 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4741 assert_eq!(fix.replacement, "#level-2-heading");
4742 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4743 }
4744
4745 #[test]
4746 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4747 let temp_dir = tempdir().unwrap();
4748 let content = "# Title\n\nSee [this file](test.md).\n";
4749 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4750
4751 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4752 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4753 assert!(
4754 result[0].fix.is_none(),
4755 "Dropping the link would change the document, so there is no fix"
4756 );
4757 }
4758
4759 #[test]
4760 fn test_the_check_is_off_by_default() {
4761 let temp_dir = tempdir().unwrap();
4762 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4763 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4764
4765 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4766 }
4767
4768 #[test]
4769 fn test_a_link_to_another_file_is_left_alone() {
4770 let temp_dir = tempdir().unwrap();
4771 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4772 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4773 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4774
4775 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4776 }
4777
4778 #[test]
4779 fn test_a_self_link_written_with_traversal_reports_once() {
4780 let temp_dir = tempdir().unwrap();
4781 let sub_dir = temp_dir.path().join("sub");
4782 std::fs::create_dir_all(&sub_dir).unwrap();
4783
4784 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4785 let config = MD057Config {
4786 self_referential_links: true,
4787 compact_paths: true,
4788 ..Default::default()
4789 };
4790 let result = check_as_file(&sub_dir, "test.md", content, config);
4791
4792 assert_eq!(
4793 result.len(),
4794 1,
4795 "A compacted path would still be a link back to this file. Got: {result:?}"
4796 );
4797 assert_eq!(
4798 result[0].message,
4799 "Relative link '../sub/test.md' points to the file it is in"
4800 );
4801 }
4802
4803 #[test]
4804 fn test_compact_paths_still_reports_a_link_to_another_file() {
4805 let temp_dir = tempdir().unwrap();
4806 let sub_dir = temp_dir.path().join("sub");
4807 std::fs::create_dir_all(&sub_dir).unwrap();
4808 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4809
4810 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4811 let config = MD057Config {
4812 self_referential_links: true,
4813 compact_paths: true,
4814 ..Default::default()
4815 };
4816 let result = check_as_file(&sub_dir, "test.md", content, config);
4817
4818 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4819 assert_eq!(
4820 result[0].message,
4821 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4822 );
4823 }
4824
4825 #[test]
4826 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4827 let temp_dir = tempdir().unwrap();
4828 let content = "# Title\n\nSee [this file](test#title).\n";
4829 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4830
4831 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4832 assert_eq!(
4833 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4834 Some("#title"),
4835 "Got: {result:?}"
4836 );
4837 }
4838
4839 #[test]
4840 fn test_a_reference_definition_pointing_at_its_own_file() {
4841 let temp_dir = tempdir().unwrap();
4842 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4843 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4844
4845 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4846 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4847 assert_eq!(fix.replacement, "#level-2-heading");
4848 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4849 }
4850
4851 #[test]
4852 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4853 let temp_dir = tempdir().unwrap();
4854 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4855 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4856
4857 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4858 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4859 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
4862 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
4863 .fix(&crate::lint_context::LintContext::new(
4864 content,
4865 crate::config::MarkdownFlavor::Standard,
4866 Some(temp_dir.path().join("test.md")),
4867 ))
4868 .unwrap();
4869 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
4870 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
4871 }
4872
4873 #[test]
4874 fn test_a_self_link_resolved_through_a_search_path() {
4875 let temp_dir = tempdir().unwrap();
4876 let guide_dir = temp_dir.path().join("docs/guide");
4877 std::fs::create_dir_all(&guide_dir).unwrap();
4878 let config = MD057Config {
4879 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4880 ..enabled()
4881 };
4882 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
4883 let result = check_as_file(&guide_dir, "test.md", content, config);
4884
4885 assert_eq!(
4886 result.len(),
4887 1,
4888 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
4889 );
4890 assert_eq!(
4891 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4892 Some("#title"),
4893 "Got: {result:?}"
4894 );
4895 }
4896
4897 #[test]
4898 fn test_a_target_next_to_the_document_outranks_a_search_path() {
4899 let temp_dir = tempdir().unwrap();
4900 let guide_dir = temp_dir.path().join("docs/guide");
4901 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
4902 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
4903 let config = MD057Config {
4904 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
4905 ..enabled()
4906 };
4907 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
4908 let result = check_as_file(&guide_dir, "test.md", content, config);
4909
4910 assert!(
4911 result.is_empty(),
4912 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
4913 );
4914 }
4915
4916 #[test]
4917 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
4918 let temp_dir = tempdir().unwrap();
4919 let content = "# Title\n\n\n";
4920 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4921
4922 assert!(
4923 result.is_empty(),
4924 "An image is not a link the reader follows. Got: {result:?}"
4925 );
4926 }
4927
4928 #[test]
4929 fn test_a_query_string_is_reported_without_a_suggestion() {
4930 let temp_dir = tempdir().unwrap();
4931 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
4932 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4933
4934 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4935 assert!(
4936 result[0].fix.is_none(),
4937 "A query does not survive losing its path. Got: {result:?}"
4938 );
4939 }
4940
4941 #[test]
4942 fn test_fix_rewrites_the_document_and_settles() {
4943 let temp_dir = tempdir().unwrap();
4944 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4945 let source_file = temp_dir.path().join("test.md");
4946 std::fs::write(&source_file, content).unwrap();
4947
4948 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
4949 let ctx = crate::lint_context::LintContext::new(
4950 content,
4951 crate::config::MarkdownFlavor::Standard,
4952 Some(source_file.clone()),
4953 );
4954 let fixed = rule.fix(&ctx).unwrap();
4955 assert_eq!(
4956 fixed,
4957 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
4958 );
4959
4960 let refixed =
4961 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
4962 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
4963 }
4964
4965 #[test]
4966 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
4967 let unfixable = MD057ExistingRelativeLinks::default();
4968 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
4969
4970 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
4971 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
4972 }
4973
4974 #[test]
4975 fn test_the_option_is_read_from_kebab_and_snake_case() {
4976 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
4977 assert!(kebab.self_referential_links);
4978
4979 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
4980 assert!(snake.self_referential_links);
4981 }
4982
4983 fn front_matter_checked() -> MD057Config {
4984 MD057Config {
4985 check_frontmatter: true,
4986 ..Default::default()
4987 }
4988 }
4989
4990 #[test]
4991 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
4992 let temp_dir = tempdir().unwrap();
4993 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
4994 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
4995
4996 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4997 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
4998 assert_eq!(result[0].line, 2);
4999 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5000 assert_eq!(result[0].end_column, 23);
5001 }
5002
5003 #[test]
5004 fn test_frontmatter_paths_are_not_checked_by_default() {
5005 let temp_dir = tempdir().unwrap();
5006 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5007 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5008
5009 assert!(
5010 result.is_empty(),
5011 "Frontmatter is only checked on request. Got: {result:?}"
5012 );
5013 }
5014
5015 #[test]
5016 fn test_an_existing_frontmatter_path_is_not_reported() {
5017 let temp_dir = tempdir().unwrap();
5018 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5019 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
5020 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5021
5022 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
5023 assert_eq!(result[0].line, 3);
5024 }
5025
5026 #[test]
5027 fn test_an_ignored_frontmatter_field_is_not_checked() {
5028 let temp_dir = tempdir().unwrap();
5029 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5030 let config = MD057Config {
5031 check_frontmatter: true,
5032 ignore_frontmatter_fields: vec!["Image".to_string()],
5033 ..Default::default()
5034 };
5035 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5036
5037 assert_eq!(
5038 result.len(),
5039 1,
5040 "The ignored field is skipped and the other is not. Got: {result:?}"
5041 );
5042 assert_eq!(result[0].line, 3);
5043 }
5044
5045 #[test]
5046 fn test_an_external_frontmatter_url_is_not_reported() {
5047 let temp_dir = tempdir().unwrap();
5048 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5049 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5050
5051 assert!(
5052 result.is_empty(),
5053 "An external URL has no local target. Got: {result:?}"
5054 );
5055 }
5056
5057 #[test]
5058 fn test_a_frontmatter_fragment_is_left_to_md051() {
5059 let temp_dir = tempdir().unwrap();
5060 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5061 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5062
5063 assert!(
5064 result.is_empty(),
5065 "A fragment names a heading, not a file. Got: {result:?}"
5066 );
5067 }
5068
5069 #[test]
5070 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5071 let temp_dir = tempdir().unwrap();
5072 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5073
5074 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5075 assert!(
5076 ignored.is_empty(),
5077 "Absolute paths are ignored by default. Got: {ignored:?}"
5078 );
5079
5080 let warning_config = MD057Config {
5081 check_frontmatter: true,
5082 absolute_links: AbsoluteLinksOption::Warn,
5083 ..Default::default()
5084 };
5085 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5086 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5087 assert_eq!(
5088 warned[0].message,
5089 "Absolute link '/docs/guide.md' cannot be validated locally"
5090 );
5091 }
5092
5093 #[test]
5094 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5095 let temp_dir = tempdir().unwrap();
5096 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5097 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5098 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5099
5100 assert_eq!(
5101 result.len(),
5102 1,
5103 "A query names no file, so only the missing target is reported. Got: {result:?}"
5104 );
5105 assert_eq!(result[0].line, 2);
5106 assert_eq!(
5107 result[0].message,
5108 "Relative link 'docs/missing.md?raw=true' does not exist"
5109 );
5110 }
5111
5112 #[test]
5113 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5114 let temp_dir = tempdir().unwrap();
5115 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5116 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5117
5118 assert!(
5119 result.is_empty(),
5120 "Only path-shaped values are destinations. Got: {result:?}"
5121 );
5122 }
5123}
5124
5125#[cfg(test)]
5130mod wrapped_link_text_tests {
5131 use super::self_referential_links_tests::check_as_file;
5132 use super::*;
5133 use tempfile::tempdir;
5134
5135 #[test]
5136 fn test_a_wrapped_link_in_a_list_item_is_reported() {
5137 let temp_dir = tempdir().unwrap();
5138 let content = "- Items reimbursable by the various [one-off\n expense](does-not-exist-anywhere)\n budgets.\n";
5139 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5140
5141 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5142 assert_eq!(
5143 result[0].message,
5144 "Relative link 'does-not-exist-anywhere' does not exist"
5145 );
5146 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5147 assert_eq!(result[0].end_line, 2);
5148 assert_eq!(result[0].column, 12);
5151 assert_eq!(result[0].end_column, 35);
5152 }
5153
5154 #[test]
5155 fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5156 let temp_dir = tempdir().unwrap();
5157 let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5158 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5159
5160 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5161 assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5162 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5163 assert_eq!(result[0].column, 7);
5166 assert_eq!(result[0].end_column, 19);
5167 }
5168
5169 #[test]
5170 fn test_a_wrapped_link_carrying_a_title_is_reported() {
5171 let temp_dir = tempdir().unwrap();
5172 let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5173 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5174
5175 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5176 assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5177 assert_eq!(result[0].line, 2);
5178 assert_eq!(result[0].column, 7);
5181 assert_eq!(result[0].end_column, 24);
5182 }
5183
5184 #[test]
5185 fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5186 let temp_dir = tempdir().unwrap();
5187 std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5188 let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5189 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5190
5191 assert!(result.is_empty(), "The target exists. Got: {result:?}");
5192 }
5193
5194 #[test]
5195 fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5196 let rule = MD057ExistingRelativeLinks::new();
5197 let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5198
5199 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5200 let mut index = FileIndex::new();
5201 rule.contribute_to_index(&ctx, &mut index);
5202
5203 let targets: Vec<&str> = index
5204 .md057_link_targets
5205 .iter()
5206 .map(|target| target.target.as_str())
5207 .collect();
5208 assert_eq!(
5209 targets,
5210 vec!["./docs/guide.md"],
5211 "The index must record the target so a cached verdict is invalidated when the file appears"
5212 );
5213 }
5214
5215 #[test]
5216 fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5217 let temp_dir = tempdir().unwrap();
5218 let sub_dir = temp_dir.path().join("sub");
5219 std::fs::create_dir_all(&sub_dir).unwrap();
5220 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5221
5222 let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5223 let config = MD057Config {
5224 compact_paths: true,
5225 ..Default::default()
5226 };
5227 let result = check_as_file(&sub_dir, "test.md", content, config);
5228
5229 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5230 assert_eq!(
5231 result[0].message,
5232 "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5233 );
5234 assert_eq!(result[0].line, 2);
5235 let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5236 assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5237 assert_eq!(fix.replacement, "other.md#part");
5238 }
5239
5240 #[test]
5241 fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5242 let temp_dir = tempdir().unwrap();
5243 let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5244 let config = MD057Config {
5245 self_referential_links: true,
5246 ..Default::default()
5247 };
5248 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5249
5250 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5251 assert_eq!(
5252 result[0].message,
5253 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5254 );
5255 assert_eq!(result[0].line, 4);
5256 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5257 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5258 assert_eq!(fix.replacement, "#level-2-heading");
5259 }
5260}
5261
5262#[cfg(test)]
5268mod destination_boundary_tests {
5269 use super::self_referential_links_tests::check_as_file;
5270 use super::*;
5271 use tempfile::tempdir;
5272
5273 fn compacting() -> MD057Config {
5274 MD057Config {
5275 compact_paths: true,
5276 ..Default::default()
5277 }
5278 }
5279
5280 #[test]
5284 fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5285 let temp_dir = tempdir().unwrap();
5286 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5287 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5288
5289 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5290
5291 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5292 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5293 assert_eq!(result[0].line, 1);
5294 assert_eq!(result[0].column, 13);
5296 assert_eq!(result[0].end_column, 23);
5297 assert!(result[0].fix.is_none(), "A missing target carries no fix");
5298 }
5299
5300 #[test]
5303 fn test_compaction_never_rewrites_a_title() {
5304 let temp_dir = tempdir().unwrap();
5305 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5306 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5307
5308 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5309
5310 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5311 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5312 assert_eq!(result[0].column, 13);
5313 assert!(
5314 result.iter().all(|warning| warning.fix.is_none()),
5315 "Nothing in this document is rewritable. Got: {result:?}"
5316 );
5317 }
5318
5319 #[test]
5322 fn test_a_nested_bracket_does_not_close_the_label() {
5323 let temp_dir = tempdir().unwrap();
5324 let content = "[see [note]](./missing.md)\n";
5325
5326 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5327
5328 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5329 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5330 assert_eq!(result[0].column, 14);
5332 assert_eq!(result[0].end_column, 26);
5333 }
5334
5335 #[test]
5339 fn test_an_autolink_carries_no_destination() {
5340 let temp_dir = tempdir().unwrap();
5341 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5342 let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5343
5344 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5345
5346 assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5347 }
5348
5349 #[test]
5351 fn test_an_email_autolink_carries_no_destination() {
5352 let temp_dir = tempdir().unwrap();
5353 let content = "<user@example.com>\n";
5354
5355 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5356
5357 assert!(
5358 result.is_empty(),
5359 "An email autolink is not a relative link. Got: {result:?}"
5360 );
5361 }
5362
5363 #[test]
5368 fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5369 let temp_dir = tempdir().unwrap();
5370 std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5371 let content = "[``a\n](./exists.md)``](exists.md)\n";
5372
5373 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5374
5375 assert!(
5376 result.is_empty(),
5377 "Both destinations exist, so nothing is reported. Got: {result:?}"
5378 );
5379 assert!(
5380 result.iter().all(|warning| warning.fix.is_none()),
5381 "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5382 );
5383 }
5384
5385 #[test]
5389 fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5390 let temp_dir = tempdir().unwrap();
5391 let content = "[<i title=\"[\">text</i>](missing.md)\n";
5392
5393 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5394
5395 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5396 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5397 assert_eq!(result[0].line, 1);
5398 assert_eq!(result[0].column, 25);
5400 assert_eq!(result[0].end_column, 35);
5401 }
5402
5403 #[test]
5407 fn test_a_link_inside_an_image_description_is_not_a_link() {
5408 let temp_dir = tempdir().unwrap();
5409 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5410 let content = "](exists.png)\n";
5411
5412 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5413
5414 assert!(
5415 result.is_empty(),
5416 "The description of an image carries no link. Got: {result:?}"
5417 );
5418 }
5419
5420 #[test]
5424 fn test_a_link_inside_literal_brackets_is_still_a_link() {
5425 let temp_dir = tempdir().unwrap();
5426 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5427 let content = "[outer [inner](missing2.md)](exists.png)\n";
5428
5429 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5430
5431 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5432 assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5433 assert_eq!(result[0].line, 1);
5434 assert_eq!(result[0].column, 16);
5436 }
5437
5438 #[test]
5441 fn test_an_image_of_its_own_is_still_reported() {
5442 let temp_dir = tempdir().unwrap();
5443 let content = "\n";
5444
5445 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5446
5447 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5448 assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5449 assert_eq!(result[0].column, 1);
5450 }
5451
5452 #[test]
5455 fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5456 let temp_dir = tempdir().unwrap();
5457 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5458 let content = "[](missing.md)\n";
5459
5460 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5461
5462 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5463 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5464 assert_eq!(result[0].column, 22);
5466 }
5467
5468 #[test]
5472 fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5473 let temp_dir = tempdir().unwrap();
5474 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5475 let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5476
5477 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5478
5479 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5480 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5481 assert_eq!(result[0].line, 1);
5482 assert_eq!(result[0].column, 12);
5484 assert_eq!(result[0].end_column, 22);
5485 }
5486
5487 #[test]
5492 fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5493 for content in [
5494 "][nodef]\n",
5495 "][]\n",
5496 "]\n",
5497 ] {
5498 let temp_dir = tempdir().unwrap();
5499
5500 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5501
5502 assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5503 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5504 assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5506 }
5507 }
5508
5509 #[test]
5512 fn test_an_image_with_an_empty_destination_is_still_an_image() {
5513 let temp_dir = tempdir().unwrap();
5514 let content = "]()\n";
5515
5516 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5517
5518 assert!(
5519 result.is_empty(),
5520 "An image with no destination is still an image. Got: {result:?}"
5521 );
5522 }
5523
5524 #[test]
5528 fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5529 let temp_dir = tempdir().unwrap();
5530 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5531 let content = " [link](missing.md)](exists.png)\n";
5532
5533 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5534
5535 assert!(
5536 result.is_empty(),
5537 "The link sits inside the outer image's description. Got: {result:?}"
5538 );
5539 }
5540
5541 #[test]
5544 fn test_a_link_after_an_image_is_a_link() {
5545 let temp_dir = tempdir().unwrap();
5546 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5547 let content = " [link](missing.md)\n";
5548
5549 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5550
5551 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5552 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5553 assert_eq!(result[0].column, 29);
5555 }
5556
5557 #[test]
5559 fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5560 let temp_dir = tempdir().unwrap();
5561 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5562 let content = "][def]\n\n[def]: exists.png\n";
5563
5564 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5565
5566 assert!(
5567 result.is_empty(),
5568 "The description of a resolved reference image carries no link. Got: {result:?}"
5569 );
5570 }
5571
5572 #[test]
5575 fn test_a_destination_on_the_next_line_is_reported_there() {
5576 let temp_dir = tempdir().unwrap();
5577 let content = "[a](\n ./gone.md)\n";
5578
5579 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5580
5581 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5582 assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5583 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5584 assert_eq!(result[0].end_line, 2);
5585 assert_eq!(result[0].column, 3);
5587 assert_eq!(result[0].end_column, 12);
5588 }
5589}
5590
5591#[cfg(test)]
5599mod exact_case_tests {
5600 use super::self_referential_links_tests::check_as_file;
5601 use super::*;
5602 use tempfile::tempdir;
5603
5604 fn volume_folds_case(dir: &Path) -> bool {
5609 let probe = dir.join("case-fold-probe.tmp");
5610 std::fs::write(&probe, "").unwrap();
5611 let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5612 std::fs::remove_file(&probe).unwrap();
5613 folded
5614 }
5615
5616 fn write_case_fixture(dir: &Path) {
5620 std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5621 std::fs::create_dir_all(dir.join("Docs")).unwrap();
5622 std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5623 }
5624
5625 #[test]
5631 fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5632 let temp_dir = tempdir().unwrap();
5633 let anchor = temp_dir.path();
5634 write_case_fixture(anchor);
5635 reset_file_existence_cache();
5636
5637 assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5638 assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5639 }
5640
5641 #[test]
5642 fn test_only_the_stored_spelling_of_a_target_exists() {
5643 let temp_dir = tempdir().unwrap();
5644 let anchor = temp_dir.path();
5645 write_case_fixture(anchor);
5646 reset_file_existence_cache();
5647
5648 for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5649 assert!(
5650 exists_exact_case(anchor, &anchor.join(target)),
5651 "{target} is on disk under this spelling"
5652 );
5653 }
5654 for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5655 assert!(
5656 !exists_exact_case(anchor, &anchor.join(target)),
5657 "{target} is not on disk under this spelling"
5658 );
5659 }
5660 }
5661
5662 #[test]
5665 fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5666 let temp_dir = tempdir().unwrap();
5667 let anchor = temp_dir.path();
5668 write_case_fixture(anchor);
5669 reset_file_existence_cache();
5670
5671 assert!(resolve_existing_target(anchor, &anchor.join("Foo")).is_some());
5672 assert!(resolve_existing_target(anchor, &anchor.join("foo")).is_none());
5673 }
5674
5675 #[test]
5678 fn test_a_target_reached_through_a_parent_step_is_checked() {
5679 let temp_dir = tempdir().unwrap();
5680 write_case_fixture(temp_dir.path());
5681 let anchor = temp_dir.path().join("Docs");
5682 reset_file_existence_cache();
5683
5684 assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5685 assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5686 }
5687
5688 #[test]
5691 fn test_a_component_above_the_anchor_is_accepted() {
5692 let temp_dir = tempdir().unwrap();
5693 write_case_fixture(temp_dir.path());
5694 let anchor = temp_dir.path().join("docs");
5695 reset_file_existence_cache();
5696
5697 assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5698 }
5699
5700 #[cfg(unix)]
5704 #[test]
5705 fn test_a_link_through_a_symlinked_directory_exists() {
5706 let temp_dir = tempdir().unwrap();
5707 let anchor = temp_dir.path();
5708 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5709 std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5710 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5711 reset_file_existence_cache();
5712
5713 assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5714 }
5715
5716 #[cfg(target_os = "macos")]
5719 #[test]
5720 fn test_a_decomposed_name_matches_a_composed_link() {
5721 let temp_dir = tempdir().unwrap();
5722 let anchor = temp_dir.path();
5723 std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5724 reset_file_existence_cache();
5725
5726 assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5727 assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5728 }
5729
5730 #[test]
5731 fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5732 let temp_dir = tempdir().unwrap();
5733 write_case_fixture(temp_dir.path());
5734 let content = "\
5735[a](foo.md)\n\
5736[b](docs/Guide.md)\n\
5737[c](foo)\n\
5738[d](docs)\n\
5739[e](Foo.md)\n\
5740[f](Docs/Guide.md)\n\
5741[g](Docs)\n\
5742[h](Bar.md)\n";
5743
5744 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5745
5746 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5747 assert_eq!(
5748 messages,
5749 vec![
5750 "Relative link 'foo.md' does not exist",
5751 "Relative link 'docs/Guide.md' does not exist",
5752 "Relative link 'foo' does not exist",
5753 "Relative link 'docs' does not exist",
5754 "Relative link 'Bar.md' does not exist",
5755 ],
5756 "Got: {result:?}"
5757 );
5758 }
5759
5760 #[test]
5763 fn test_the_reporters_link_is_reported() {
5764 let temp_dir = tempdir().unwrap();
5765 std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5766 let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5767
5768 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5769
5770 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5771 assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5772 assert_eq!(result[0].line, 3);
5773 assert_eq!(result[0].column, 21);
5774 }
5775
5776 #[test]
5779 fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5780 let temp_dir = tempdir().unwrap();
5781 std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5782 let content = "[a](Guide.html)\n[b](guide.html)\n";
5783
5784 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5785
5786 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5787 assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5788 assert_eq!(result[0].line, 2);
5789 }
5790
5791 #[test]
5794 fn test_a_search_path_answers_for_the_exact_spelling() {
5795 let temp_dir = tempdir().unwrap();
5796 let assets = temp_dir.path().join("assets");
5797 std::fs::create_dir_all(&assets).unwrap();
5798 std::fs::write(assets.join("Logo.png"), "").unwrap();
5799 let config = MD057Config {
5800 search_paths: vec![assets.to_string_lossy().into_owned()],
5801 ..Default::default()
5802 };
5803 let content = "[a](Logo.png)\n[b](logo.png)\n";
5804
5805 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5806
5807 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5808 assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5809 assert_eq!(result[0].line, 2);
5810 }
5811
5812 #[test]
5815 fn test_an_absolute_link_under_a_root_keeps_its_case() {
5816 let temp_dir = tempdir().unwrap();
5817 let root = temp_dir.path();
5818 write_case_fixture(root);
5819 let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5820
5821 let config = MD057Config {
5822 absolute_links: AbsoluteLinksOption::RelativeToRoots,
5823 roots: vec![],
5824 ..Default::default()
5825 };
5826 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5827 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5828 let result = rule.check(&ctx).unwrap();
5829
5830 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5831 assert_eq!(
5832 messages,
5833 vec![
5834 "Absolute link '/docs/Guide.md' was not found under the project root",
5835 "Absolute link '/docs' was not found under the project root",
5836 ],
5837 "Got: {result:?}"
5838 );
5839 }
5840
5841 #[cfg(unix)]
5846 #[test]
5847 fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5848 let temp_dir = tempdir().unwrap();
5849 let anchor = temp_dir.path();
5850 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5851 std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5852 std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5853 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5854 let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5855
5856 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5857
5858 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5859 assert_eq!(
5860 messages,
5861 vec![
5862 "Relative link 'docs/../Foo.md' does not exist",
5863 "Relative link 'docs/bar.md' does not exist",
5864 ],
5865 "Got: {result:?}"
5866 );
5867 }
5868
5869 #[cfg(unix)]
5874 struct RestoreMode {
5875 directory: PathBuf,
5876 mode: u32,
5877 }
5878
5879 #[cfg(unix)]
5880 impl RestoreMode {
5881 fn take(directory: &Path) -> Self {
5882 use std::os::unix::fs::PermissionsExt;
5883 Self {
5884 directory: directory.to_path_buf(),
5885 mode: std::fs::metadata(directory).unwrap().permissions().mode(),
5886 }
5887 }
5888 }
5889
5890 #[cfg(unix)]
5891 impl Drop for RestoreMode {
5892 fn drop(&mut self) {
5893 use std::os::unix::fs::PermissionsExt;
5894 let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
5895 }
5896 }
5897
5898 #[cfg(unix)]
5902 #[test]
5903 fn test_a_directory_that_cannot_be_listed_is_accepted() {
5904 use std::os::unix::fs::PermissionsExt;
5905
5906 let temp_dir = tempdir().unwrap();
5907 let anchor = temp_dir.path();
5908 let locked = anchor.join("locked");
5909 std::fs::create_dir_all(&locked).unwrap();
5910 std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
5911
5912 let _restore = RestoreMode::take(&locked);
5913 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
5914 if std::fs::read_dir(&locked).is_ok() {
5915 return;
5918 }
5919 assert!(locked.join("Target.md").exists(), "the directory can still be entered");
5920 let content = "[a](locked/Target.md)\n";
5921
5922 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5923
5924 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
5925 }
5926
5927 #[cfg(target_os = "macos")]
5931 #[test]
5932 fn test_a_composed_name_matches_a_decomposed_link() {
5933 let temp_dir = tempdir().unwrap();
5934 let anchor = temp_dir.path();
5935 std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
5936 reset_file_existence_cache();
5937
5938 assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
5939 assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
5940
5941 let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
5942
5943 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5944
5945 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5946 assert_eq!(
5947 messages,
5948 vec!["Relative link 'CAFE\u{301}.md' does not exist"],
5949 "Got: {result:?}"
5950 );
5951 }
5952
5953 #[cfg(target_os = "macos")]
5958 #[test]
5959 fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
5960 let temp_dir = tempdir().unwrap();
5961 let anchor = temp_dir.path();
5962 std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
5963 reset_file_existence_cache();
5964
5965 assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
5966 assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
5967
5968 let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
5969
5970 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5971
5972 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5973 assert_eq!(
5974 messages,
5975 vec!["Relative link 'kelvin.md' does not exist"],
5976 "Got: {result:?}"
5977 );
5978 }
5979
5980 #[cfg(target_os = "macos")]
5984 #[test]
5985 fn test_an_ascii_name_matches_a_link_composing_to_it() {
5986 let temp_dir = tempdir().unwrap();
5987 let anchor = temp_dir.path();
5988 std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
5989 reset_file_existence_cache();
5990
5991 assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
5992 assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
5993
5994 let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.md)\n";
5995
5996 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5997
5998 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5999 assert_eq!(
6000 messages,
6001 vec!["Relative link '\u{212a}ELVIN.md' does not exist"],
6002 "Got: {result:?}"
6003 );
6004 }
6005
6006 fn seed_listing(directory: &Path, modified: SystemTime) {
6014 let listing = DirectoryListing {
6015 modified: Some(modified),
6016 listed: true,
6017 names: HashSet::from([OsString::from("Other.md")]),
6018 composed: HashSet::new(),
6019 };
6020 let key = std::fs::canonicalize(directory).unwrap();
6021 DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6022 }
6023
6024 fn set_directory_modified(directory: &Path, modified: SystemTime) {
6027 let mut options = std::fs::OpenOptions::new();
6028 options.read(true);
6029 #[cfg(windows)]
6030 {
6031 use std::os::windows::fs::OpenOptionsExt;
6032 options.access_mode(0x0100).custom_flags(0x02000000);
6035 }
6036 options
6037 .open(directory)
6038 .unwrap()
6039 .set_times(std::fs::FileTimes::new().set_modified(modified))
6040 .unwrap();
6041 }
6042
6043 #[test]
6048 fn test_a_listing_answers_until_the_directory_changes() {
6049 let temp_dir = tempdir().unwrap();
6050 let anchor = temp_dir.path();
6051 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6052 std::fs::write(anchor.join("test.md"), "").unwrap();
6055 let initial_time = SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000_000_000);
6056 set_directory_modified(anchor, initial_time);
6057 let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6058 seed_listing(anchor, read_at);
6059
6060 let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6061
6062 let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6063 assert_eq!(
6064 messages,
6065 vec!["Relative link 'README.md' does not exist"],
6066 "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6067 );
6068
6069 std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6070 set_directory_modified(anchor, initial_time + std::time::Duration::from_secs(10));
6071 assert_ne!(
6072 std::fs::metadata(anchor).unwrap().modified().unwrap(),
6073 read_at,
6074 "the fixture must expose a distinct directory modification time"
6075 );
6076
6077 let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6078
6079 assert!(
6080 accepted.is_empty(),
6081 "the listing is read again once the directory has moved on. Got: {accepted:?}"
6082 );
6083 }
6084
6085 #[test]
6089 fn test_a_listing_from_another_moment_is_discarded() {
6090 let temp_dir = tempdir().unwrap();
6091 let anchor = temp_dir.path();
6092 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6093 seed_listing(anchor, SystemTime::UNIX_EPOCH);
6094
6095 let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6096
6097 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6098 }
6099}