1use crate::rule::{
7 CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity,
8};
9use crate::utils::frontmatter_values;
10use crate::utils::range_utils::byte_to_char_count;
11use crate::workspace_index::{
12 FileIndex, LinkOrigin, Md057LinkTarget, URL_EXTRACT_ANGLE_BRACKET_REGEX, URL_EXTRACT_REGEX,
13 extract_cross_file_links, link_path_part, normalize_relative_path, url_decode,
14};
15use pulldown_cmark::LinkType;
16use regex::Regex;
17use std::borrow::Cow;
18use std::cell::{LazyCell, RefCell};
19use std::collections::{HashMap, HashSet};
20use std::env;
21use std::ffi::{OsStr, OsString};
22use std::path::{Component, Path, PathBuf};
23use std::sync::LazyLock;
24use std::sync::{Arc, Mutex};
25use std::time::SystemTime;
26use unicode_normalization::UnicodeNormalization;
27
28mod link_resolver;
29mod md057_config;
30use crate::utils::mkdocs_config::resolve_docs_dir;
31use crate::utils::obsidian_config::resolve_attachment_folder;
32use crate::utils::project_root::project_root;
33#[cfg(test)]
34use link_resolver::is_external_url;
35pub use link_resolver::{DocumentLinks, LinkResolution, LinkResolver};
36pub use md057_config::{AbsoluteLinksOption, MD057Config};
37
38thread_local! {
39 static FILE_EXISTENCE_CACHE: RefCell<HashMap<PathBuf, bool>> = RefCell::new(HashMap::new());
44}
45
46type DirectoryListingCache = Arc<Mutex<HashMap<PathBuf, Arc<DirectoryListing>>>>;
49
50static DIRECTORY_LISTING_CACHE: LazyLock<DirectoryListingCache> =
54 LazyLock::new(|| Arc::new(Mutex::new(HashMap::new())));
55
56fn reset_file_existence_cache() {
63 FILE_EXISTENCE_CACHE.with_borrow_mut(HashMap::clear);
64}
65
66struct DirectoryListing {
69 modified: Option<SystemTime>,
73 listed: bool,
76 names: HashSet<OsString>,
78 composed: HashSet<String>,
82}
83
84impl DirectoryListing {
85 fn unlisted(modified: Option<SystemTime>) -> Self {
88 Self {
89 modified,
90 listed: false,
91 names: HashSet::new(),
92 composed: HashSet::new(),
93 }
94 }
95
96 fn read(directory: &Path) -> Self {
97 let modified = std::fs::metadata(directory)
101 .and_then(|metadata| metadata.modified())
102 .ok();
103 let mut names = HashSet::new();
104 let mut composed = HashSet::new();
105 let Ok(entries) = std::fs::read_dir(directory) else {
106 return Self::unlisted(modified);
107 };
108 for entry in entries {
109 let Ok(entry) = entry else {
110 return Self::unlisted(modified);
114 };
115 let name = entry.file_name();
116 if let Some(text) = name.to_str()
117 && !text.is_ascii()
118 {
119 composed.insert(text.nfc().collect());
120 }
121 names.insert(name);
122 }
123 Self {
124 modified,
125 listed: true,
126 names,
127 composed,
128 }
129 }
130
131 fn holds(&self, name: &OsStr) -> bool {
138 if !self.listed {
139 return true;
140 }
141 if self.names.contains(name) {
142 return true;
143 }
144 let Some(text) = name.to_str() else {
145 return false;
146 };
147 if text.is_ascii() {
148 return self.composed.contains(text);
149 }
150 let composed: String = text.nfc().collect();
151 self.composed.contains(composed.as_str()) || self.names.contains(OsStr::new(composed.as_str()))
152 }
153}
154
155fn directory_listing(directory: &Path) -> Arc<DirectoryListing> {
172 let Ok(modified) = std::fs::metadata(directory).and_then(|metadata| metadata.modified()) else {
173 return Arc::new(DirectoryListing::read(directory));
176 };
177 match DIRECTORY_LISTING_CACHE.lock() {
178 Ok(cache) => {
179 if let Some(listing) = cache.get(directory)
180 && listing.modified == Some(modified)
181 {
182 return Arc::clone(listing);
183 }
184 }
185 Err(_) => return Arc::new(DirectoryListing::read(directory)), }
187 let listing = Arc::new(DirectoryListing::read(directory));
188 match DIRECTORY_LISTING_CACHE.lock() {
189 Ok(mut cache) => {
190 cache.insert(directory.to_path_buf(), Arc::clone(&listing));
197 listing
198 }
199 Err(_) => listing, }
201}
202
203fn file_exists_with_cache(path: &Path) -> bool {
205 if let Some(exists) = FILE_EXISTENCE_CACHE.with_borrow(|cache| cache.get(path).copied()) {
206 return exists;
207 }
208 let exists = path.exists();
209 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(path.to_path_buf(), exists));
210 exists
211}
212
213fn has_exact_case_components(anchor: &Path, path: &Path) -> bool {
226 let Ok(relative) = path.strip_prefix(anchor) else {
227 return true;
228 };
229 let mut directory = if anchor.as_os_str().is_empty() {
230 PathBuf::from(".")
231 } else {
232 anchor.to_path_buf()
233 };
234 for component in relative.components() {
235 match component {
236 Component::CurDir => {}
237 Component::ParentDir => {
238 if matches!(directory.components().next_back(), Some(Component::Normal(_))) {
242 directory.pop();
243 } else {
244 directory.push("..");
245 }
246 if directory.as_os_str().is_empty() {
249 directory.push(".");
250 }
251 }
252 Component::Normal(name) => {
253 if !directory_listing(&directory).holds(name) {
254 return false;
255 }
256 directory.push(name);
257 }
258 Component::RootDir | Component::Prefix(_) => return true,
261 }
262 }
263 true
264}
265
266fn exists_exact_case(anchor: &Path, path: &Path) -> bool {
275 file_exists_with_cache(path) && has_exact_case_components(anchor, path)
276}
277
278fn resolve_existing_target(anchor: &Path, path: &Path) -> Option<PathBuf> {
283 if exists_exact_case(anchor, path) {
285 return Some(path.to_path_buf());
286 }
287
288 if path.extension().is_none() {
290 for ext in MARKDOWN_EXTENSIONS {
291 let path_with_ext = path.with_extension(&ext[1..]);
293 if exists_exact_case(anchor, &path_with_ext) {
294 return Some(path_with_ext);
295 }
296 }
297 }
298
299 None
300}
301
302fn html_markdown_sources(path: &Path) -> impl Iterator<Item = PathBuf> + '_ {
305 let source = path
306 .extension()
307 .and_then(|ext| ext.to_str())
308 .filter(|ext| ext.eq_ignore_ascii_case("html") || ext.eq_ignore_ascii_case("htm"))
309 .and(path.file_stem().and_then(|stem| stem.to_str()))
310 .zip(path.parent());
311 source.into_iter().flat_map(|(stem, parent)| {
312 MARKDOWN_EXTENSIONS
313 .iter()
314 .map(move |md_ext| parent.join(format!("{stem}{md_ext}")))
315 })
316}
317
318static PROTOCOL_DOMAIN_REGEX: LazyLock<Regex> =
322 LazyLock::new(|| Regex::new(r"^([a-zA-Z][a-zA-Z0-9+.-]*://|[a-zA-Z][a-zA-Z0-9+.-]*:|www\.)").unwrap());
323
324static CURRENT_DIR: LazyLock<PathBuf> = LazyLock::new(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
326
327const MARKDOWN_EXTENSIONS: &[&str] = &[
329 ".md",
330 ".markdown",
331 ".mdx",
332 ".mkd",
333 ".mkdn",
334 ".mdown",
335 ".mdwn",
336 ".qmd",
337 ".rmd",
338];
339
340struct DocumentAnchor {
342 self_path: Option<PathBuf>,
346 base_path: PathBuf,
350 search_paths: Vec<PathBuf>,
352}
353
354type LazyAnchor<'a> = LazyCell<DocumentAnchor, Box<dyn FnOnce() -> DocumentAnchor + 'a>>;
359
360#[cfg(test)]
361thread_local! {
362 static ANCHOR_RESOLUTIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
365}
366
367#[derive(Debug, PartialEq, Eq)]
369enum SelfReferentialLink {
370 WholeFile,
373 Fragment(String),
376}
377
378#[cfg(feature = "blake3")]
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum DependencyPathState {
381 Missing,
382 File,
383 Directory,
384 Other,
385}
386
387#[derive(Debug, Clone)]
389pub struct MD057ExistingRelativeLinks {
390 base_path: Arc<Mutex<Option<PathBuf>>>,
395 config: MD057Config,
397}
398
399impl Default for MD057ExistingRelativeLinks {
400 fn default() -> Self {
401 Self {
402 base_path: Arc::new(Mutex::new(None)),
403 config: MD057Config::default(),
404 }
405 }
406}
407
408impl MD057ExistingRelativeLinks {
409 pub fn new() -> Self {
411 Self::default()
412 }
413
414 pub fn with_path<P: AsRef<Path>>(self, path: P) -> Self {
416 let path = path.as_ref();
417 let dir_path = if path.is_file() {
418 path.parent().map(std::path::Path::to_path_buf)
419 } else {
420 Some(path.to_path_buf())
421 };
422
423 if let Ok(mut guard) = self.base_path.lock() {
424 *guard = dir_path;
425 }
426 self
427 }
428
429 pub fn from_config_struct(config: MD057Config) -> Self {
430 Self {
431 base_path: Arc::new(Mutex::new(None)),
432 config,
433 }
434 }
435
436 fn resolve_against_project_root(path_str: &str, project_root: &Path) -> PathBuf {
440 if Path::new(path_str).is_absolute() {
441 PathBuf::from(path_str)
442 } else {
443 project_root.join(path_str)
444 }
445 }
446
447 #[inline]
450 fn is_non_file_destination(&self, url: &str, flavor: crate::config::MarkdownFlavor) -> bool {
451 link_resolver::is_non_file_destination(url, flavor)
452 }
453
454 #[inline]
456 fn is_fragment_only_link(&self, url: &str) -> bool {
457 url.starts_with('#')
458 }
459
460 #[inline]
463 fn is_absolute_path(url: &str) -> bool {
464 url.starts_with('/')
465 }
466
467 fn strip_query_and_fragment(url: &str) -> &str {
475 let query_pos = url.find('?');
478 let fragment_pos = url.find('#');
479
480 match (query_pos, fragment_pos) {
481 (Some(q), Some(f)) => {
482 &url[..q.min(f)]
484 }
485 (Some(q), None) => &url[..q],
486 (None, Some(f)) => &url[..f],
487 (None, None) => url,
488 }
489 }
490
491 fn resolve_link_path_with_base(link: &str, base_path: &Path) -> PathBuf {
493 base_path.join(link)
494 }
495
496 fn compute_search_paths(
501 &self,
502 flavor: crate::config::MarkdownFlavor,
503 source_file: Option<&Path>,
504 base_path: &Path,
505 project_root: &Path,
506 ) -> Vec<PathBuf> {
507 Self::search_paths_for(&self.config.search_paths, flavor, source_file, base_path, project_root)
508 }
509
510 fn search_paths_for(
514 configured: &[String],
515 flavor: crate::config::MarkdownFlavor,
516 source_file: Option<&Path>,
517 base_path: &Path,
518 project_root: &Path,
519 ) -> Vec<PathBuf> {
520 let mut paths = Vec::new();
521
522 if flavor == crate::config::MarkdownFlavor::Obsidian
524 && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path)
525 && attachment_dir != *base_path
526 {
527 paths.push(attachment_dir);
528 }
529
530 for search_path in configured {
534 let resolved = Self::resolve_against_project_root(search_path, project_root);
535 if resolved != *base_path && !paths.contains(&resolved) {
536 paths.push(resolved);
537 }
538 }
539
540 paths
541 }
542
543 fn contribute_dependency_targets(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
548 for destination in body_link_destinations(ctx) {
549 let url = destination.url;
550 if url.is_empty()
551 || (url.starts_with('`') && url.ends_with('`'))
552 || self.is_non_file_destination(url, ctx.flavor)
553 || self.is_fragment_only_link(url)
554 {
555 continue;
556 }
557 index.add_md057_link_target(Md057LinkTarget {
558 target: url.to_string(),
559 origin: LinkOrigin::Body,
560 });
561 }
562
563 for image in ctx.images() {
564 if ctx
565 .line_info(image.line)
566 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
567 || matches!(image.link_type, LinkType::WikiLink { .. })
568 || ctx.is_in_shortcode(image.byte_offset)
569 {
570 continue;
571 }
572 let url = image.url.as_ref();
573 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
574 continue;
575 }
576 index.add_md057_link_target(Md057LinkTarget {
577 target: url.to_string(),
578 origin: LinkOrigin::Body,
579 });
580 }
581
582 for reference in ctx.reference_definitions() {
583 if ctx.line_info(reference.line).is_some_and(|info| info.in_front_matter) {
584 continue;
585 }
586 let url = reference.url.as_str();
587 if url.is_empty() || self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
588 continue;
589 }
590 index.add_md057_link_target(Md057LinkTarget {
591 target: url.to_string(),
592 origin: LinkOrigin::Body,
593 });
594 }
595
596 for link in frontmatter_values::link_destinations(ctx) {
597 let line = ctx.lines[link.line - 1].content(ctx.content);
598 let url = &line[link.range];
599 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
600 continue;
601 }
602 index.add_md057_link_target(Md057LinkTarget {
603 target: url.to_string(),
604 origin: LinkOrigin::FrontMatter { field: link.field },
605 });
606 }
607 }
608
609 fn resolve_in_search_paths(
612 decoded_path: &str,
613 search_paths: &[PathBuf],
614 policy: Option<&crate::lint_context::LinkTargetPolicy>,
615 ) -> Option<PathBuf> {
616 search_paths
617 .iter()
618 .find_map(|dir| Self::resolve_target(dir, &dir.join(decoded_path), policy))
619 }
620
621 fn resolve_target(
626 anchor: &Path,
627 path: &Path,
628 policy: Option<&crate::lint_context::LinkTargetPolicy>,
629 ) -> Option<PathBuf> {
630 if let Some(supplied) = policy.and_then(|policy| policy.resolve_supplied(path)) {
631 return Some(supplied);
632 }
633 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
634 return None;
635 }
636 resolve_existing_target(anchor, path)
637 }
638
639 fn missing_relative_message(url: &str, policy: Option<&crate::lint_context::LinkTargetPolicy>) -> String {
640 if policy.is_some_and(|policy| !policy.allow_disk_fallback()) {
641 format!("Relative link '{url}' target not in the supplied document set")
642 } else {
643 format!("Relative link '{url}' does not exist")
644 }
645 }
646
647 fn compact_path_suggestion(&self, url: &str, base_path: &Path) -> Option<String> {
653 if !self.config.compact_paths {
654 return None;
655 }
656
657 let path_end = url
659 .find('?')
660 .unwrap_or(url.len())
661 .min(url.find('#').unwrap_or(url.len()));
662 let path_part = &url[..path_end];
663 let suffix = &url[path_end..];
664
665 let decoded_path = url_decode(path_part);
667
668 compute_compact_path(base_path, &decoded_path).map(|compact| format!("{compact}{suffix}"))
669 }
670
671 fn self_referential_link(
683 &self,
684 url: &str,
685 base_path: &Path,
686 search_paths: &[PathBuf],
687 source_file: Option<&Path>,
688 policy: Option<&crate::lint_context::LinkTargetPolicy>,
689 ) -> Option<SelfReferentialLink> {
690 if !self.config.self_referential_links {
691 return None;
692 }
693 let source_file = source_file?;
694
695 let path_part = Self::strip_query_and_fragment(url);
696 if path_part.is_empty() {
697 return None;
698 }
699 let suffix = &url[path_part.len()..];
700
701 let decoded_path = url_decode(path_part);
702 let resolved = std::iter::once(base_path)
706 .chain(search_paths.iter().map(PathBuf::as_path))
707 .find_map(|dir| {
708 Self::resolve_target(dir, &Self::resolve_link_path_with_base(&decoded_path, dir), policy)
709 })?;
710 if !Self::is_same_file(&resolved, source_file) {
711 return None;
712 }
713
714 match suffix.strip_prefix('#') {
718 Some(fragment) if !fragment.is_empty() => Some(SelfReferentialLink::Fragment(suffix.to_string())),
719 _ => Some(SelfReferentialLink::WholeFile),
720 }
721 }
722
723 fn ref_def_url_range(content: &str, ref_def: &crate::lint_context::ReferenceDef) -> Option<std::ops::Range<usize>> {
730 let def = content.get(ref_def.byte_offset..ref_def.byte_end)?;
731 let label_end = Self::label_end(def)?;
732 let search_end = ref_def.title_byte_start.map_or(def.len(), |title| {
733 title.saturating_sub(ref_def.byte_offset).min(def.len())
734 });
735 let offset = def.get(label_end..search_end)?.find(ref_def.url.as_str())? + label_end;
736 let start = ref_def.byte_offset + offset;
737 Some(start..start + ref_def.url.len())
738 }
739
740 fn label_end(def: &str) -> Option<usize> {
745 let bytes = def.as_bytes();
746 let mut i = 0;
747 while i < bytes.len() {
748 match bytes[i] {
749 b'\\' => i += 2,
750 b']' if bytes.get(i + 1) == Some(&b':') => return Some(i + 2),
751 _ => i += 1,
752 }
753 }
754 None
755 }
756
757 fn checks_front_matter_of(&self, ctx: &crate::lint_context::LintContext) -> bool {
761 self.config.check_frontmatter && ctx.front_matter_end_line() > 0
762 }
763
764 fn absolute_link_message(
767 &self,
768 url: &str,
769 anchor: &LazyAnchor<'_>,
770 project_root: &Path,
771 policy: Option<&crate::lint_context::LinkTargetPolicy>,
772 ) -> Option<String> {
773 match self.config.absolute_links {
774 AbsoluteLinksOption::Ignore => None,
775 AbsoluteLinksOption::Warn => Some(format!("Absolute link '{url}' cannot be validated locally")),
776 AbsoluteLinksOption::RelativeToDocs => {
777 Self::validate_absolute_link_via_docs_dir(url, &anchor.base_path, policy)
778 }
779 AbsoluteLinksOption::RelativeToRoots => {
780 Self::validate_absolute_link_via_roots(url, &self.config.roots, project_root, policy)
781 }
782 }
783 }
784
785 fn check_front_matter(
792 &self,
793 ctx: &crate::lint_context::LintContext,
794 anchor: &LazyAnchor<'_>,
795 project_root: &Path,
796 warnings: &mut Vec<LintWarning>,
797 ) {
798 if !self.config.check_frontmatter {
799 return;
800 }
801
802 let ignored: HashSet<String> = self
803 .config
804 .ignore_frontmatter_fields
805 .iter()
806 .map(|field| field.to_lowercase())
807 .collect();
808
809 for link in frontmatter_values::link_destinations(ctx) {
810 if link.field_is_in(&ignored) {
811 continue;
812 }
813
814 let line = ctx.lines[link.line - 1].content(ctx.content);
815 let url = &line[link.range.clone()];
816
817 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
820 continue;
821 }
822
823 let column = byte_to_char_count(line, link.range.start);
824 let end_column = column + url.chars().count();
825
826 if Self::is_absolute_path(url) {
827 if let Some(message) = self.absolute_link_message(url, anchor, project_root, ctx.link_target_policy()) {
828 warnings.push(LintWarning {
829 rule_name: Some(self.name().to_string()),
830 line: link.line,
831 column,
832 end_line: link.line,
833 end_column,
834 message,
835 severity: Severity::Warning,
836 fix: None,
837 });
838 }
839 continue;
840 }
841
842 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
843 continue;
844 }
845
846 warnings.push(LintWarning {
847 rule_name: Some(self.name().to_string()),
848 line: link.line,
849 column,
850 end_line: link.line,
851 end_column,
852 message: Self::missing_relative_message(url, ctx.link_target_policy()),
853 severity: Severity::Error,
854 fix: None,
855 });
856 }
857 }
858
859 fn relative_target_exists(
861 url: &str,
862 base_path: &Path,
863 search_paths: &[PathBuf],
864 policy: Option<&crate::lint_context::LinkTargetPolicy>,
865 ) -> bool {
866 Self::resolve_relative(url, base_path, search_paths, policy).is_some()
867 }
868
869 fn resolve_relative(
876 url: &str,
877 base_path: &Path,
878 search_paths: &[PathBuf],
879 policy: Option<&crate::lint_context::LinkTargetPolicy>,
880 ) -> Option<PathBuf> {
881 let decoded_path = link_path_part(url);
882 let resolved_path = Self::resolve_link_path_with_base(&decoded_path, base_path);
883
884 if let Some(target) = Self::resolve_target(base_path, &resolved_path, policy) {
886 return Some(target);
887 }
888
889 if let Some(source) =
890 html_markdown_sources(&resolved_path).find_map(|source| Self::resolve_target(base_path, &source, policy))
891 {
892 return Some(source);
893 }
894
895 Self::resolve_in_search_paths(&decoded_path, search_paths, policy)
896 }
897
898 fn document_anchor(
901 &self,
902 flavor: crate::config::MarkdownFlavor,
903 source_file: Option<&Path>,
904 explicit_base: Option<PathBuf>,
905 project_root: &Path,
906 ) -> DocumentAnchor {
907 #[cfg(test)]
908 ANCHOR_RESOLUTIONS.with(|count| count.set(count.get() + 1));
909 let self_path =
910 source_file.map(|source_file| source_file.canonicalize().unwrap_or_else(|_| source_file.to_path_buf()));
911 let base_path = explicit_base.unwrap_or_else(|| {
912 self_path
913 .as_deref()
914 .and_then(Path::parent)
915 .map_or_else(|| CURRENT_DIR.clone(), Path::to_path_buf)
916 });
917 let search_paths = self.compute_search_paths(flavor, source_file, &base_path, project_root);
918 DocumentAnchor {
919 self_path,
920 base_path,
921 search_paths,
922 }
923 }
924
925 fn produces_fixes(&self) -> bool {
929 self.config.compact_paths || self.config.self_referential_links
930 }
931
932 fn self_referential_message(url: &str, self_link: &SelfReferentialLink) -> String {
934 match self_link {
935 SelfReferentialLink::Fragment(fragment) => {
936 format!("Relative link '{url}' points to the file it is in and can be simplified to '{fragment}'")
937 }
938 SelfReferentialLink::WholeFile => {
939 format!("Relative link '{url}' points to the file it is in")
940 }
941 }
942 }
943
944 fn is_same_file(resolved: &Path, source_file: &Path) -> bool {
950 if resolved.file_name() != source_file.file_name() {
952 return false;
953 }
954 match (resolved.canonicalize(), source_file.canonicalize()) {
955 (Ok(link), Ok(source)) => link == source,
956 _ => normalize_relative_path(resolved) == normalize_relative_path(source_file),
957 }
958 }
959
960 fn validate_absolute_link_via_docs_dir(
965 url: &str,
966 source_path: &Path,
967 policy: Option<&crate::lint_context::LinkTargetPolicy>,
968 ) -> Option<String> {
969 let Some(resolution) = Self::resolve_absolute_via_docs_dir(url, source_path, policy) else {
970 return Some(format!(
971 "Absolute link '{url}' cannot be validated locally (no mkdocs.yml found)"
972 ));
973 };
974
975 match resolution {
976 Resolution::Found(_) => None,
977 Resolution::DirectoryWithoutIndex { resolved } => Some(format!(
978 "Absolute link '{url}' resolves to directory '{}' which has no index.md",
979 resolved.display()
980 )),
981 Resolution::NotFound { resolved } => Some(format!(
982 "Absolute link '{url}' resolves to '{}' which does not exist",
983 resolved.display()
984 )),
985 }
986 }
987
988 fn validate_absolute_link_via_roots(
997 url: &str,
998 roots: &[String],
999 project_root: &Path,
1000 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1001 ) -> Option<String> {
1002 if Self::resolve_absolute_via_roots(url, roots, project_root, policy).is_some() {
1003 return None;
1004 }
1005
1006 let msg = if roots.is_empty() {
1007 format!("Absolute link '{url}' was not found under the project root")
1008 } else {
1009 format!("Absolute link '{url}' was not found under any configured root or the project root")
1010 };
1011 Some(msg)
1012 }
1013
1014 fn resolve_absolute_via_docs_dir(
1020 url: &str,
1021 source_path: &Path,
1022 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1023 ) -> Option<Resolution> {
1024 let docs_dir = resolve_docs_dir(source_path)?;
1025 let decoded = Self::prepare_absolute_url(url);
1026 Some(Self::resolve_under_root_with_opts(&docs_dir, &decoded, true, policy))
1027 }
1028
1029 fn resolve_absolute_via_roots(
1035 url: &str,
1036 roots: &[String],
1037 project_root: &Path,
1038 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1039 ) -> Option<PathBuf> {
1040 let decoded = Self::prepare_absolute_url(url);
1041 roots
1042 .iter()
1043 .map(|root| Self::resolve_against_project_root(root, project_root))
1044 .chain(std::iter::once(project_root.to_path_buf()))
1045 .find_map(
1046 |root| match Self::resolve_under_root_with_opts(&root, &decoded, false, policy) {
1047 Resolution::Found(target) => Some(target),
1048 _ => None,
1049 },
1050 )
1051 }
1052
1053 fn prepare_absolute_url(url: &str) -> String {
1056 let relative_url = url.trim_start_matches('/');
1057 let file_path = Self::strip_query_and_fragment(relative_url);
1058 url_decode(file_path)
1059 }
1060
1061 fn resolve_under_root_with_opts(
1094 root_path: &Path,
1095 decoded: &str,
1096 require_index_for_dirs: bool,
1097 policy: Option<&crate::lint_context::LinkTargetPolicy>,
1098 ) -> Resolution {
1099 let resolved = root_path.join(decoded);
1100
1101 if let Some(policy) = policy {
1102 if let Some(resolution) = Self::resolve_supplied_under_root(&resolved, require_index_for_dirs, policy) {
1103 return resolution;
1104 }
1105 if !policy.allow_disk_fallback() {
1106 return Resolution::NotFound { resolved };
1107 }
1108 }
1109
1110 if resolved.is_dir() && exists_exact_case(root_path, &resolved) {
1111 if !require_index_for_dirs {
1112 return Resolution::Found(resolved);
1113 }
1114 let index = resolved.join("index.md");
1115 return if exists_exact_case(root_path, &index) {
1116 Resolution::Found(index)
1117 } else {
1118 Resolution::DirectoryWithoutIndex { resolved }
1119 };
1120 }
1121
1122 if let Some(target) = resolve_existing_target(root_path, &resolved) {
1123 return Resolution::Found(target);
1124 }
1125
1126 if let Some(source) = html_markdown_sources(&resolved).find(|source| exists_exact_case(root_path, source)) {
1129 return Resolution::Found(source);
1130 }
1131
1132 Resolution::NotFound { resolved }
1133 }
1134
1135 fn resolve_supplied_under_root(
1141 resolved: &Path,
1142 require_index_for_dirs: bool,
1143 policy: &crate::lint_context::LinkTargetPolicy,
1144 ) -> Option<Resolution> {
1145 let Some(supplied) = policy.resolve_supplied(resolved) else {
1146 return html_markdown_sources(resolved)
1147 .find(|source| policy.contains(source))
1148 .map(Resolution::Found);
1149 };
1150 if policy.contains(&supplied) || !require_index_for_dirs {
1151 return Some(Resolution::Found(supplied));
1152 }
1153 let index = supplied.join("index.md");
1154 if policy.contains(&index) {
1155 Some(Resolution::Found(index))
1156 } else if policy.allow_disk_fallback() {
1157 None
1158 } else {
1159 Some(Resolution::DirectoryWithoutIndex {
1160 resolved: resolved.to_path_buf(),
1161 })
1162 }
1163 }
1164}
1165
1166#[cfg(feature = "blake3")]
1170impl MD057ExistingRelativeLinks {
1171 pub fn cache_dependency_fingerprint(
1177 &self,
1178 source_file: &Path,
1179 flavor: crate::config::MarkdownFlavor,
1180 file_index: &FileIndex,
1181 ) -> String {
1182 let mut hasher = blake3::Hasher::new();
1183 hasher.update(b"rumdl-md057-dependencies-v1");
1184 if file_index.md057_link_targets.is_empty() {
1185 return hasher.finalize().to_hex().to_string();
1186 }
1187
1188 let explicit_base = self.base_path.lock().ok().and_then(|guard| guard.clone());
1189 let project_root = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1190 let anchor = LazyCell::new(|| self.document_anchor(flavor, Some(source_file), explicit_base, &project_root));
1194 let ignored_frontmatter_fields: HashSet<String> = self
1195 .config
1196 .ignore_frontmatter_fields
1197 .iter()
1198 .map(|field| field.to_lowercase())
1199 .collect();
1200
1201 for dependency in &file_index.md057_link_targets {
1202 if let LinkOrigin::FrontMatter { field } = &dependency.origin
1203 && (!self.config.check_frontmatter
1204 || field
1205 .as_ref()
1206 .is_some_and(|field| ignored_frontmatter_fields.contains(field)))
1207 {
1208 continue;
1209 }
1210
1211 let url = dependency.target.as_str();
1212 if self.is_non_file_destination(url, flavor) || self.is_fragment_only_link(url) {
1213 continue;
1214 }
1215
1216 Self::hash_bytes(&mut hasher, url.as_bytes());
1217 if Self::is_absolute_path(url) {
1218 match self.config.absolute_links {
1219 AbsoluteLinksOption::Ignore | AbsoluteLinksOption::Warn => {}
1220 AbsoluteLinksOption::RelativeToDocs => {
1221 hasher.update(b"docs");
1222 if let Some(docs_dir) = resolve_docs_dir(source_file) {
1223 Self::observe_absolute_resolution(&mut hasher, &docs_dir, url, true);
1224 } else {
1225 hasher.update(b"no-docs-dir");
1226 }
1227 }
1228 AbsoluteLinksOption::RelativeToRoots => {
1229 hasher.update(b"roots");
1230 let decoded = Self::prepare_absolute_url(url);
1231 let mut found = false;
1232 for root in &self.config.roots {
1233 let root_path = Self::resolve_against_project_root(root, &project_root);
1234 if Self::observe_under_root(&mut hasher, &root_path, &decoded, false) {
1235 found = true;
1236 break;
1237 }
1238 }
1239 if !found {
1240 Self::observe_under_root(&mut hasher, &project_root, &decoded, false);
1241 }
1242 }
1243 }
1244 } else {
1245 hasher.update(b"relative");
1246 if self.config.self_referential_links
1247 && Self::observe_self_referential_resolution(
1248 &mut hasher,
1249 url,
1250 &anchor.base_path,
1251 &anchor.search_paths,
1252 anchor.self_path.as_deref().unwrap_or(source_file),
1253 )
1254 {
1255 continue;
1256 }
1257 Self::observe_relative_resolution(&mut hasher, url, &anchor.base_path, &anchor.search_paths);
1258 }
1259 }
1260
1261 hasher.finalize().to_hex().to_string()
1262 }
1263
1264 fn hash_bytes(hasher: &mut blake3::Hasher, bytes: &[u8]) {
1265 hasher.update(&(bytes.len() as u64).to_le_bytes());
1266 hasher.update(bytes);
1267 }
1268
1269 fn hash_path(hasher: &mut blake3::Hasher, path: &Path) {
1270 #[cfg(unix)]
1271 {
1272 use std::os::unix::ffi::OsStrExt;
1273 Self::hash_bytes(hasher, path.as_os_str().as_bytes());
1274 }
1275 #[cfg(windows)]
1276 {
1277 use std::os::windows::ffi::OsStrExt;
1278 let encoded: Vec<u8> = path.as_os_str().encode_wide().flat_map(u16::to_le_bytes).collect();
1279 Self::hash_bytes(hasher, &encoded);
1280 }
1281 #[cfg(not(any(unix, windows)))]
1282 Self::hash_bytes(hasher, path.to_string_lossy().as_bytes());
1283 }
1284
1285 fn observe_path(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> DependencyPathState {
1290 Self::hash_path(hasher, path);
1291 let state = if exists_exact_case(anchor, path) {
1292 match std::fs::metadata(path) {
1293 Ok(metadata) if metadata.is_file() => DependencyPathState::File,
1294 Ok(metadata) if metadata.is_dir() => DependencyPathState::Directory,
1295 Ok(_) => DependencyPathState::Other,
1296 Err(_) => DependencyPathState::Missing,
1297 }
1298 } else {
1299 DependencyPathState::Missing
1300 };
1301 hasher.update(&[match state {
1302 DependencyPathState::Missing => 0,
1303 DependencyPathState::File => 1,
1304 DependencyPathState::Directory => 2,
1305 DependencyPathState::Other => 3,
1306 }]);
1307 state
1308 }
1309
1310 fn observe_existing_target(hasher: &mut blake3::Hasher, anchor: &Path, path: &Path) -> Option<PathBuf> {
1311 if Self::observe_path(hasher, anchor, path) != DependencyPathState::Missing {
1312 return Some(path.to_path_buf());
1313 }
1314 if path.extension().is_none() {
1315 for extension in MARKDOWN_EXTENSIONS {
1316 let candidate = path.with_extension(&extension[1..]);
1317 if Self::observe_path(hasher, anchor, &candidate) != DependencyPathState::Missing {
1318 return Some(candidate);
1319 }
1320 }
1321 }
1322 None
1323 }
1324
1325 fn observe_self_referential_resolution(
1326 hasher: &mut blake3::Hasher,
1327 url: &str,
1328 base_path: &Path,
1329 search_paths: &[PathBuf],
1330 source_file: &Path,
1331 ) -> bool {
1332 let decoded = url_decode(Self::strip_query_and_fragment(url));
1333 for directory in std::iter::once(base_path).chain(search_paths.iter().map(PathBuf::as_path)) {
1334 let candidate = Self::resolve_link_path_with_base(&decoded, directory);
1335 if let Some(resolved) = Self::observe_existing_target(hasher, directory, &candidate) {
1336 let canonical = resolved.canonicalize().unwrap_or(resolved);
1337 hasher.update(b"resolved-identity");
1338 Self::hash_path(hasher, &canonical);
1339 return Self::is_same_file(&canonical, source_file);
1340 }
1341 }
1342 false
1343 }
1344
1345 fn observe_relative_resolution(hasher: &mut blake3::Hasher, url: &str, base_path: &Path, search_paths: &[PathBuf]) {
1346 let decoded = url_decode(Self::strip_query_and_fragment(url));
1347 let resolved = Self::resolve_link_path_with_base(&decoded, base_path);
1348 if Self::observe_existing_target(hasher, base_path, &resolved).is_some() {
1349 return;
1350 }
1351
1352 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1353 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1354 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1355 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1356 Self::observe_path(hasher, base_path, &parent.join(format!("{stem}{extension}")))
1357 != DependencyPathState::Missing
1358 })
1359 {
1360 return;
1361 }
1362
1363 for search_path in search_paths {
1364 if Self::observe_existing_target(hasher, search_path, &search_path.join(&decoded)).is_some() {
1365 return;
1366 }
1367 }
1368 }
1369
1370 fn observe_absolute_resolution(
1371 hasher: &mut blake3::Hasher,
1372 root: &Path,
1373 url: &str,
1374 require_index_for_dirs: bool,
1375 ) -> bool {
1376 let decoded = Self::prepare_absolute_url(url);
1377 Self::observe_under_root(hasher, root, &decoded, require_index_for_dirs)
1378 }
1379
1380 fn observe_under_root(
1384 hasher: &mut blake3::Hasher,
1385 root: &Path,
1386 decoded: &str,
1387 require_index_for_dirs: bool,
1388 ) -> bool {
1389 let resolved = root.join(decoded);
1390 let resolved_state = Self::observe_path(hasher, root, &resolved);
1391
1392 if resolved_state == DependencyPathState::Directory {
1393 if !require_index_for_dirs {
1394 return true;
1395 }
1396 return Self::observe_path(hasher, root, &resolved.join("index.md")) != DependencyPathState::Missing;
1397 }
1398
1399 if resolved_state != DependencyPathState::Missing {
1400 return true;
1401 }
1402 if resolved.extension().is_none()
1403 && MARKDOWN_EXTENSIONS.iter().any(|extension| {
1404 Self::observe_path(hasher, root, &resolved.with_extension(&extension[1..]))
1405 != DependencyPathState::Missing
1406 })
1407 {
1408 return true;
1409 }
1410
1411 if let Some(extension) = resolved.extension().and_then(|extension| extension.to_str())
1412 && (extension.eq_ignore_ascii_case("html") || extension.eq_ignore_ascii_case("htm"))
1413 && let (Some(stem), Some(parent)) = (resolved.file_stem().and_then(|stem| stem.to_str()), resolved.parent())
1414 {
1415 return MARKDOWN_EXTENSIONS.iter().any(|extension| {
1416 Self::observe_path(hasher, root, &parent.join(format!("{stem}{extension}")))
1417 != DependencyPathState::Missing
1418 });
1419 }
1420
1421 false
1422 }
1423}
1424
1425enum Resolution {
1429 Found(PathBuf),
1430 DirectoryWithoutIndex { resolved: PathBuf },
1431 NotFound { resolved: PathBuf },
1432}
1433
1434fn extract_url_at<'a>(re: &Regex, line: &'a str, expected_start: usize) -> Option<regex::Captures<'a>> {
1445 let caps = re.captures_at(line, expected_start)?;
1446 if caps.get(0)?.start() != expected_start {
1447 return None;
1448 }
1449 Some(caps)
1450}
1451
1452struct BodyLinkDestination<'a> {
1454 url: &'a str,
1457 fragment: &'a str,
1460 url_range: std::ops::Range<usize>,
1462 fix_range: std::ops::Range<usize>,
1465}
1466
1467impl<'a> BodyLinkDestination<'a> {
1468 fn full_url(&self) -> Cow<'a, str> {
1471 if self.fragment.is_empty() {
1472 Cow::Borrowed(self.url)
1473 } else {
1474 Cow::Owned(format!("{}{}", self.url, self.fragment))
1475 }
1476 }
1477}
1478
1479fn body_link_destinations<'ctx>(
1497 ctx: &'ctx crate::lint_context::LintContext<'_>,
1498) -> impl Iterator<Item = BodyLinkDestination<'ctx>> + 'ctx {
1499 let mut pending_images = ctx.images().iter().peekable();
1507 let mut open_images: Vec<&crate::lint_context::ParsedImage<'_>> = Vec::new();
1508 let mut previous_link_offset = 0usize;
1509 ctx.links().iter().filter_map(move |link| {
1510 debug_assert!(
1511 link.byte_offset >= previous_link_offset,
1512 "the links arrive sorted by start offset, which is what lets one forward pass over the images find the ones around each link"
1513 );
1514 previous_link_offset = link.byte_offset;
1515 if !matches!(link.link_type, LinkType::Inline) {
1516 return None;
1517 }
1518 if ctx
1519 .line_info(link.line)
1520 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1521 {
1522 return None;
1523 }
1524 if ctx.is_in_code_span_byte(link.byte_offset)
1525 || ctx.is_in_math_span(link.byte_offset)
1526 || ctx.is_in_shortcode(link.byte_offset)
1527 {
1528 return None;
1529 }
1530 while let Some(image) = pending_images.next_if(|image| image.byte_offset <= link.byte_offset) {
1536 close_images_ending_by(&mut open_images, image.byte_offset);
1537 open_images.push(image);
1538 }
1539 close_images_ending_by(&mut open_images, link.byte_offset);
1540 if open_images
1541 .iter()
1542 .any(|image| link.byte_end <= image.byte_end && renders_as_image(ctx, image))
1543 {
1544 return None;
1545 }
1546 locate_destination(ctx.content, link)
1547 })
1548}
1549
1550fn close_images_ending_by(open_images: &mut Vec<&crate::lint_context::ParsedImage<'_>>, offset: usize) {
1554 while open_images.last().is_some_and(|image| image.byte_end <= offset) {
1555 open_images.pop();
1556 }
1557}
1558
1559fn renders_as_image(ctx: &crate::lint_context::LintContext<'_>, image: &crate::lint_context::ParsedImage<'_>) -> bool {
1567 if !image.is_reference {
1568 return true;
1569 }
1570 image
1571 .reference_id
1572 .as_ref()
1573 .is_some_and(|id| ctx.reference_definition(id).is_some())
1574}
1575
1576fn locate_destination<'a>(
1590 content: &'a str,
1591 link: &crate::lint_context::ParsedLink<'_>,
1592) -> Option<BodyLinkDestination<'a>> {
1593 let span = content.get(link.byte_offset..link.byte_end)?;
1594 let anchor = 1 + link.text.len();
1595 if !span.get(anchor..).is_some_and(|rest| rest.starts_with("](")) {
1596 return None;
1597 }
1598 let caps = extract_url_at(&URL_EXTRACT_ANGLE_BRACKET_REGEX, span, anchor)
1599 .or_else(|| extract_url_at(&URL_EXTRACT_REGEX, span, anchor))?;
1600 let url_group = caps.get(1)?;
1601 let fragment = caps.get(2);
1602
1603 let span_start = link.byte_offset;
1604 let url_range = span_start + url_group.start()..span_start + url_group.end();
1605 let fix_end = fragment.map_or(url_range.end, |group| span_start + group.end());
1606
1607 Some(BodyLinkDestination {
1608 url: url_group.as_str().trim(),
1609 fragment: fragment.map_or("", |group| group.as_str()),
1610 fix_range: url_range.start..fix_end,
1611 url_range,
1612 })
1613}
1614
1615impl Rule for MD057ExistingRelativeLinks {
1616 fn name(&self) -> &'static str {
1617 "MD057"
1618 }
1619
1620 fn description(&self) -> &'static str {
1621 "Relative links should point to existing files"
1622 }
1623
1624 fn category(&self) -> RuleCategory {
1625 RuleCategory::Link
1626 }
1627
1628 fn skippable_by_category(&self) -> bool {
1629 !self.config.check_frontmatter
1632 }
1633
1634 fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
1635 ctx.content.is_empty() || (!ctx.likely_has_links_or_images() && !self.checks_front_matter_of(ctx))
1636 }
1637
1638 fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
1639 let content = ctx.content;
1640
1641 if content.is_empty() {
1642 return Ok(Vec::new());
1643 }
1644
1645 let has_body_links = content.contains('[') && (content.contains("](") || content.contains("]:"));
1649 if !has_body_links && !self.checks_front_matter_of(ctx) {
1650 return Ok(Vec::new());
1651 }
1652
1653 reset_file_existence_cache();
1655
1656 let mut warnings = Vec::new();
1657
1658 let explicit_base = self.base_path.lock().ok().and_then(|g| g.clone());
1662
1663 let project_root: PathBuf = explicit_base.clone().unwrap_or_else(|| project_root().to_path_buf());
1667
1668 let source_file = ctx.source_file();
1671 if explicit_base.is_none() && source_file.is_none() {
1672 return Ok(warnings);
1673 }
1674 let anchor: LazyAnchor<'_> = LazyCell::new(Box::new(|| {
1677 self.document_anchor(ctx.flavor, source_file, explicit_base, &project_root)
1678 }));
1679
1680 for destination in body_link_destinations(ctx) {
1685 let url = destination.url;
1686
1687 if url.is_empty() {
1689 continue;
1690 }
1691
1692 if url.starts_with('`') && url.ends_with('`') {
1696 continue;
1697 }
1698
1699 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1701 continue;
1702 }
1703
1704 if Self::is_absolute_path(url) {
1706 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1707 {
1708 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1709 warnings.push(LintWarning {
1710 rule_name: Some(self.name().to_string()),
1711 line,
1712 column,
1713 end_line: line,
1714 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1715 message,
1716 severity: Severity::Warning,
1717 fix: None,
1718 });
1719 }
1720 continue;
1721 }
1722
1723 let full_url = destination.full_url();
1726
1727 if let Some(self_link) = self.self_referential_link(
1732 &full_url,
1733 &anchor.base_path,
1734 &anchor.search_paths,
1735 anchor.self_path.as_deref(),
1736 ctx.link_target_policy(),
1737 ) {
1738 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1739 warnings.push(LintWarning {
1740 rule_name: Some(self.name().to_string()),
1741 line,
1742 column,
1743 end_line: line,
1744 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1745 message: Self::self_referential_message(&full_url, &self_link),
1746 severity: Severity::Warning,
1747 fix: match &self_link {
1748 SelfReferentialLink::Fragment(fragment) => {
1749 Some(Fix::new(destination.fix_range.clone(), fragment.clone()))
1750 }
1751 SelfReferentialLink::WholeFile => None,
1752 },
1753 });
1754 continue;
1755 }
1756
1757 if let Some(suggestion) = self.compact_path_suggestion(&full_url, &anchor.base_path) {
1758 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1759 warnings.push(LintWarning {
1760 rule_name: Some(self.name().to_string()),
1761 line,
1762 column,
1763 end_line: line,
1764 end_column: ctx.offset_to_line_col(destination.fix_range.end).1,
1765 message: format!("Relative link '{full_url}' can be simplified to '{suggestion}'"),
1766 severity: Severity::Warning,
1767 fix: Some(Fix::new(destination.fix_range.clone(), suggestion)),
1768 });
1769 }
1770
1771 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1772 continue;
1773 }
1774
1775 let (line, column) = ctx.offset_to_line_col(destination.url_range.start);
1777 warnings.push(LintWarning {
1778 rule_name: Some(self.name().to_string()),
1779 line,
1780 column,
1781 end_line: line,
1782 end_column: ctx.offset_to_line_col(destination.url_range.end).1,
1783 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1784 severity: Severity::Error,
1785 fix: None,
1786 });
1787 }
1788
1789 for image in ctx.images() {
1791 if ctx
1793 .line_info(image.line)
1794 .is_some_and(|info| info.in_front_matter || info.in_pymdown_block)
1795 {
1796 continue;
1797 }
1798
1799 if matches!(image.link_type, LinkType::WikiLink { .. }) {
1803 continue;
1804 }
1805
1806 if ctx.is_in_shortcode(image.byte_offset) {
1809 continue;
1810 }
1811
1812 let url = image.url.as_ref();
1813
1814 if url.is_empty() {
1816 continue;
1817 }
1818
1819 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1821 continue;
1822 }
1823
1824 if Self::is_absolute_path(url) {
1826 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1827 {
1828 warnings.push(LintWarning {
1829 rule_name: Some(self.name().to_string()),
1830 line: image.line,
1831 column: image.start_col + 1,
1832 end_line: image.line,
1833 end_column: image.start_col + 1 + url.chars().count(),
1834 message,
1835 severity: Severity::Warning,
1836 fix: None,
1837 });
1838 }
1839 continue;
1840 }
1841
1842 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1844 let fix = content[image.byte_offset..image.byte_end].find(url).map(|url_offset| {
1847 let fix_byte_start = image.byte_offset + url_offset;
1848 let fix_byte_end = fix_byte_start + url.len();
1849 Fix::new(fix_byte_start..fix_byte_end, suggestion.clone())
1850 });
1851
1852 let image_line = ctx.raw_lines().get(image.line - 1).copied().unwrap_or("");
1853 let img_line_start_byte = ctx.line_start_byte(image.line).unwrap_or(0);
1854 let url_col = fix.as_ref().map_or(image.start_col + 1, |f| {
1857 byte_to_char_count(image_line, f.range.start - img_line_start_byte)
1858 });
1859 warnings.push(LintWarning {
1860 rule_name: Some(self.name().to_string()),
1861 line: image.line,
1862 column: url_col,
1863 end_line: image.line,
1864 end_column: url_col + url.chars().count(),
1865 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1866 severity: Severity::Warning,
1867 fix,
1868 });
1869 }
1870
1871 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1872 continue;
1873 }
1874
1875 warnings.push(LintWarning {
1878 rule_name: Some(self.name().to_string()),
1879 line: image.line,
1880 column: image.start_col + 1,
1881 end_line: image.line,
1882 end_column: image.start_col + 1 + url.chars().count(),
1883 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1884 severity: Severity::Error,
1885 fix: None,
1886 });
1887 }
1888
1889 for ref_def in ctx.reference_definitions() {
1891 if ctx.line_info(ref_def.line).is_some_and(|info| info.in_front_matter) {
1892 continue;
1893 }
1894 let url = &ref_def.url;
1895
1896 if url.is_empty() {
1898 continue;
1899 }
1900
1901 if self.is_non_file_destination(url, ctx.flavor) || self.is_fragment_only_link(url) {
1903 continue;
1904 }
1905
1906 let url_range = Self::ref_def_url_range(ctx.content, ref_def);
1910 let (line, col) = url_range
1911 .as_ref()
1912 .map_or((ref_def.line, 1), |range| ctx.offset_to_line_col(range.start));
1913 let end_col = col + url.chars().count();
1914
1915 if Self::is_absolute_path(url) {
1917 if let Some(message) = self.absolute_link_message(url, &anchor, &project_root, ctx.link_target_policy())
1918 {
1919 warnings.push(LintWarning {
1920 rule_name: Some(self.name().to_string()),
1921 line,
1922 column: col,
1923 end_line: line,
1924 end_column: end_col,
1925 message,
1926 severity: Severity::Warning,
1927 fix: None,
1928 });
1929 }
1930 continue;
1931 }
1932
1933 if let Some(self_link) = self.self_referential_link(
1935 url,
1936 &anchor.base_path,
1937 &anchor.search_paths,
1938 anchor.self_path.as_deref(),
1939 ctx.link_target_policy(),
1940 ) {
1941 warnings.push(LintWarning {
1942 rule_name: Some(self.name().to_string()),
1943 line,
1944 column: col,
1945 end_line: line,
1946 end_column: end_col,
1947 message: Self::self_referential_message(url, &self_link),
1948 severity: Severity::Warning,
1949 fix: match (&self_link, &url_range) {
1950 (SelfReferentialLink::Fragment(fragment), Some(range)) => {
1951 Some(Fix::new(range.clone(), fragment.clone()))
1952 }
1953 _ => None,
1954 },
1955 });
1956 continue;
1957 }
1958
1959 if let Some(suggestion) = self.compact_path_suggestion(url, &anchor.base_path) {
1961 warnings.push(LintWarning {
1962 rule_name: Some(self.name().to_string()),
1963 line,
1964 column: col,
1965 end_line: line,
1966 end_column: end_col,
1967 message: format!("Relative link '{url}' can be simplified to '{suggestion}'"),
1968 severity: Severity::Warning,
1969 fix: url_range.clone().map(|range| Fix::new(range, suggestion)),
1970 });
1971 }
1972
1973 if Self::relative_target_exists(url, &anchor.base_path, &anchor.search_paths, ctx.link_target_policy()) {
1974 continue;
1975 }
1976
1977 warnings.push(LintWarning {
1979 rule_name: Some(self.name().to_string()),
1980 line,
1981 column: col,
1982 end_line: line,
1983 end_column: end_col,
1984 message: Self::missing_relative_message(url, ctx.link_target_policy()),
1985 severity: Severity::Error,
1986 fix: None,
1987 });
1988 }
1989
1990 self.check_front_matter(ctx, &anchor, &project_root, &mut warnings);
1991
1992 Ok(warnings)
1993 }
1994
1995 fn fix_capability(&self) -> FixCapability {
1996 if self.produces_fixes() {
1997 FixCapability::ConditionallyFixable
1998 } else {
1999 FixCapability::Unfixable
2000 }
2001 }
2002
2003 fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
2004 if !self.produces_fixes() {
2005 return Ok(ctx.content.to_string());
2006 }
2007
2008 let warnings = self.check(ctx)?;
2009 let warnings =
2010 crate::utils::fix_utils::filter_warnings_by_inline_config(warnings, ctx.inline_config(), self.name());
2011 let mut content = ctx.content.to_string();
2012
2013 let mut fixes: Vec<_> = warnings.iter().filter_map(|w| w.fix.as_ref()).collect();
2015 fixes.sort_by_key(|f| std::cmp::Reverse(f.range.start));
2016
2017 let mut last_applied_start: Option<usize> = None;
2023 for fix in fixes {
2024 if let Some(prev_start) = last_applied_start
2025 && fix.range.end > prev_start
2026 {
2027 continue;
2028 }
2029 if fix.range.end <= content.len() {
2030 content.replace_range(fix.range.clone(), &fix.replacement);
2031 last_applied_start = Some(fix.range.start);
2032 }
2033 }
2034
2035 Ok(content)
2036 }
2037
2038 fn as_any(&self) -> &dyn std::any::Any {
2039 self
2040 }
2041
2042 crate::impl_rule_config_sections!(MD057Config);
2043
2044 fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
2045 where
2046 Self: Sized,
2047 {
2048 let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config);
2049 Box::new(Self::from_config_struct(rule_config))
2053 }
2054
2055 fn cross_file_scope(&self) -> CrossFileScope {
2056 CrossFileScope::Workspace
2057 }
2058
2059 fn contribute_to_index(&self, ctx: &crate::lint_context::LintContext, index: &mut FileIndex) {
2060 self.contribute_dependency_targets(ctx, index);
2061
2062 let links = extract_cross_file_links(ctx);
2065 for link in links.relative {
2066 index.add_cross_file_link(link);
2067 }
2068 for link in links.root_relative {
2071 index.add_root_relative_link(link);
2072 }
2073 }
2074
2075 fn cross_file_check(
2076 &self,
2077 _file_path: &Path,
2078 _file_index: &FileIndex,
2079 _workspace_index: &crate::workspace_index::WorkspaceIndex,
2080 ) -> LintResult {
2081 Ok(Vec::new())
2091 }
2092}
2093
2094fn shortest_relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
2099 let from_components: Vec<_> = from_dir.components().collect();
2100 let to_components: Vec<_> = to_path.components().collect();
2101
2102 let common_len = from_components
2104 .iter()
2105 .zip(to_components.iter())
2106 .take_while(|(a, b)| a == b)
2107 .count();
2108
2109 let mut result = PathBuf::new();
2110
2111 for _ in common_len..from_components.len() {
2113 result.push("..");
2114 }
2115
2116 for component in &to_components[common_len..] {
2118 result.push(component);
2119 }
2120
2121 result
2122}
2123
2124fn compute_compact_path(source_dir: &Path, raw_link_path: &str) -> Option<String> {
2130 let link_path = Path::new(raw_link_path);
2131
2132 let has_traversal = link_path
2134 .components()
2135 .any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::CurDir));
2136
2137 if !has_traversal {
2138 return None;
2139 }
2140
2141 let combined = source_dir.join(link_path);
2143 let normalized_target = normalize_relative_path(&combined);
2144
2145 let normalized_source = normalize_relative_path(source_dir);
2147 let shortest = shortest_relative_path(&normalized_source, &normalized_target);
2148
2149 if shortest != link_path {
2151 let compact = shortest.to_string_lossy().to_string();
2152 if compact.is_empty() {
2154 return None;
2155 }
2156 Some(compact.replace('\\', "/"))
2158 } else {
2159 None
2160 }
2161}
2162
2163#[cfg(test)]
2164mod tests {
2165 use super::*;
2166 use crate::workspace_index::{CrossFileLinkIndex, LinkOrigin};
2167 use std::fs::File;
2168 use std::io::Write;
2169 use tempfile::tempdir;
2170
2171 #[test]
2172 fn test_strip_query_and_fragment() {
2173 assert_eq!(
2175 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true"),
2176 "file.png"
2177 );
2178 assert_eq!(
2179 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?raw=true&version=1"),
2180 "file.png"
2181 );
2182 assert_eq!(
2183 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png?"),
2184 "file.png"
2185 );
2186
2187 assert_eq!(
2189 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section"),
2190 "file.md"
2191 );
2192 assert_eq!(
2193 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#"),
2194 "file.md"
2195 );
2196
2197 assert_eq!(
2199 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md?raw=true#section"),
2200 "file.md"
2201 );
2202
2203 assert_eq!(
2205 MD057ExistingRelativeLinks::strip_query_and_fragment("file.png"),
2206 "file.png"
2207 );
2208
2209 assert_eq!(
2211 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true"),
2212 "path/to/image.png"
2213 );
2214 assert_eq!(
2215 MD057ExistingRelativeLinks::strip_query_and_fragment("path/to/image.png?raw=true#anchor"),
2216 "path/to/image.png"
2217 );
2218
2219 assert_eq!(
2221 MD057ExistingRelativeLinks::strip_query_and_fragment("file.md#section?query"),
2222 "file.md"
2223 );
2224 }
2225
2226 #[test]
2227 fn test_url_decode() {
2228 assert_eq!(url_decode("penguin%20with%20space.jpg"), "penguin with space.jpg");
2230
2231 assert_eq!(url_decode("assets/my%20file%20name.png"), "assets/my file name.png");
2233
2234 assert_eq!(url_decode("hello%20world%21.md"), "hello world!.md");
2236
2237 assert_eq!(url_decode("%2f%2e%2e"), "/..");
2239
2240 assert_eq!(url_decode("%2F%2E%2E"), "/..");
2242
2243 assert_eq!(url_decode("%2f%2E%2e"), "/..");
2245
2246 assert_eq!(url_decode("normal-file.md"), "normal-file.md");
2248
2249 assert_eq!(url_decode("file%2.txt"), "file%2.txt");
2251
2252 assert_eq!(url_decode("file%"), "file%");
2254
2255 assert_eq!(url_decode("file%GG.txt"), "file%GG.txt");
2257
2258 assert_eq!(url_decode("file+name.txt"), "file+name.txt");
2260
2261 assert_eq!(url_decode(""), "");
2263
2264 assert_eq!(url_decode("caf%C3%A9.md"), "café.md");
2266
2267 assert_eq!(url_decode("%20%20%20"), " ");
2269
2270 assert_eq!(url_decode("path%2Fto%2Ffile.md"), "path/to/file.md");
2272
2273 assert_eq!(url_decode("hello%20world/foo%20bar.md"), "hello world/foo bar.md");
2275
2276 assert_eq!(url_decode("file%5B1%5D.md"), "file[1].md");
2278
2279 assert_eq!(url_decode("100%pure.md"), "100%pure.md");
2281 }
2282
2283 #[test]
2284 fn test_url_encoded_filenames() {
2285 let temp_dir = tempdir().unwrap();
2287 let base_path = temp_dir.path();
2288
2289 let file_with_spaces = base_path.join("penguin with space.jpg");
2291 File::create(&file_with_spaces)
2292 .unwrap()
2293 .write_all(b"image data")
2294 .unwrap();
2295
2296 let subdir = base_path.join("my images");
2298 std::fs::create_dir(&subdir).unwrap();
2299 let nested_file = subdir.join("photo 1.png");
2300 File::create(&nested_file).unwrap().write_all(b"photo data").unwrap();
2301
2302 let content = r#"
2304# Test Document with URL-Encoded Links
2305
2306
2307
2308
2309"#;
2310
2311 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2312
2313 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2314 let result = rule.check(&ctx).unwrap();
2315
2316 assert_eq!(
2318 result.len(),
2319 1,
2320 "Should only warn about missing%20file.jpg. Got: {result:?}"
2321 );
2322 assert!(
2323 result[0].message.contains("missing%20file.jpg"),
2324 "Warning should mention the URL-encoded filename"
2325 );
2326 }
2327
2328 #[test]
2329 fn test_external_urls() {
2330 assert!(is_external_url("https://example.com"));
2332 assert!(is_external_url("http://example.com"));
2333 assert!(is_external_url("ftp://example.com"));
2334 assert!(is_external_url("www.example.com"));
2335 assert!(is_external_url("example.com"));
2336
2337 assert!(is_external_url("file:///path/to/file"));
2339 assert!(is_external_url("smb://server/share"));
2340 assert!(is_external_url("macappstores://apps.apple.com/"));
2341 assert!(is_external_url("mailto:user@example.com"));
2342 assert!(is_external_url("tel:+1234567890"));
2343 assert!(is_external_url("data:text/plain;base64,SGVsbG8="));
2344 assert!(is_external_url("javascript:void(0)"));
2345 assert!(is_external_url("ssh://git@github.com/repo"));
2346 assert!(is_external_url("git://github.com/repo.git"));
2347
2348 assert!(is_external_url("user@example.com"));
2351 assert!(is_external_url("steering@kubernetes.io"));
2352 assert!(is_external_url("john.doe+filter@company.co.uk"));
2353 assert!(is_external_url("user_name@sub.domain.com"));
2354 assert!(is_external_url("firstname.lastname+tag@really.long.domain.example.org"));
2355
2356 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"));
2367 assert!(!is_external_url("/blog/2024/release.html"));
2368 assert!(!is_external_url("/react/hooks/use-state.html"));
2369 assert!(!is_external_url("/pkg/runtime"));
2370 assert!(!is_external_url("/doc/go1compat"));
2371 assert!(!is_external_url("/index.html"));
2372 assert!(!is_external_url("/assets/logo.png"));
2373
2374 assert!(MD057ExistingRelativeLinks::is_absolute_path("/api/v1/users"));
2376 assert!(MD057ExistingRelativeLinks::is_absolute_path("/blog/2024/release.html"));
2377 assert!(MD057ExistingRelativeLinks::is_absolute_path("/index.html"));
2378 assert!(!MD057ExistingRelativeLinks::is_absolute_path("./relative.md"));
2379 assert!(!MD057ExistingRelativeLinks::is_absolute_path("relative.md"));
2380
2381 assert!(is_external_url("~/assets/image.png"));
2384 assert!(is_external_url("~/components/Button.vue"));
2385 assert!(is_external_url("~assets/logo.svg")); assert!(is_external_url("@/components/Header.vue"));
2389 assert!(is_external_url("@images/photo.jpg"));
2390 assert!(is_external_url("@assets/styles.css"));
2391
2392 assert!(!is_external_url("./relative/path.md"));
2394 assert!(!is_external_url("relative/path.md"));
2395 assert!(!is_external_url("../parent/path.md"));
2396 }
2397
2398 #[test]
2399 fn test_dot_com_only_skips_bare_domains() {
2400 assert!(is_external_url("example.com"));
2402 assert!(is_external_url("sub.example.com"));
2403
2404 assert!(!is_external_url("../../vendor.com"));
2408 assert!(!is_external_url("./vendor.com"));
2409 assert!(!is_external_url("docs/vendor.com"));
2410 }
2411
2412 #[test]
2413 fn test_framework_path_aliases() {
2414 let temp_dir = tempdir().unwrap();
2416 let base_path = temp_dir.path();
2417
2418 let content = r#"
2420# Framework Path Aliases
2421
2422
2423
2424
2425
2426[Link](@/pages/about.md)
2427
2428This is a [real missing link](missing.md) that should be flagged.
2429"#;
2430
2431 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2432
2433 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2434 let result = rule.check(&ctx).unwrap();
2435
2436 assert_eq!(
2438 result.len(),
2439 1,
2440 "Should only warn about missing.md, not framework aliases. Got: {result:?}"
2441 );
2442 assert!(
2443 result[0].message.contains("missing.md"),
2444 "Warning should be for missing.md"
2445 );
2446 }
2447
2448 #[test]
2449 fn test_url_decode_security_path_traversal() {
2450 let temp_dir = tempdir().unwrap();
2453 let base_path = temp_dir.path();
2454
2455 let file_in_base = base_path.join("safe.md");
2457 File::create(&file_in_base).unwrap().write_all(b"# Safe").unwrap();
2458
2459 let content = r#"
2464[Traversal attempt](..%2F..%2Fnonexistent_dir_12345%2Fmissing.md)
2465[Double encoded](..%252F..%252Fnonexistent%252Ffile.md)
2466[Safe link](safe.md)
2467"#;
2468
2469 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2470
2471 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2472 let result = rule.check(&ctx).unwrap();
2473
2474 assert_eq!(
2477 result.len(),
2478 2,
2479 "Should have warnings for traversal attempts. Got: {result:?}"
2480 );
2481 }
2482
2483 #[test]
2484 fn test_url_encoded_utf8_filenames() {
2485 let temp_dir = tempdir().unwrap();
2487 let base_path = temp_dir.path();
2488
2489 let cafe_file = base_path.join("café.md");
2491 File::create(&cafe_file).unwrap().write_all(b"# Cafe").unwrap();
2492
2493 let content = r#"
2494[Café link](caf%C3%A9.md)
2495[Missing unicode](r%C3%A9sum%C3%A9.md)
2496"#;
2497
2498 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2499
2500 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2501 let result = rule.check(&ctx).unwrap();
2502
2503 assert_eq!(
2505 result.len(),
2506 1,
2507 "Should only warn about missing résumé.md. Got: {result:?}"
2508 );
2509 assert!(
2510 result[0].message.contains("r%C3%A9sum%C3%A9.md"),
2511 "Warning should mention the URL-encoded filename"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_url_encoded_emoji_filenames() {
2517 let temp_dir = tempdir().unwrap();
2520 let base_path = temp_dir.path();
2521
2522 let emoji_dir = base_path.join("👤 Personal");
2524 std::fs::create_dir(&emoji_dir).unwrap();
2525
2526 let file_path = emoji_dir.join("TV Shows.md");
2528 File::create(&file_path)
2529 .unwrap()
2530 .write_all(b"# TV Shows\n\nContent here.")
2531 .unwrap();
2532
2533 let content = r#"
2536# Test Document
2537
2538[TV Shows](./%F0%9F%91%A4%20Personal/TV%20Shows.md)
2539[Missing](./%F0%9F%91%A4%20Personal/Missing.md)
2540"#;
2541
2542 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2543
2544 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2545 let result = rule.check(&ctx).unwrap();
2546
2547 assert_eq!(result.len(), 1, "Should only warn about missing file. Got: {result:?}");
2549 assert!(
2550 result[0].message.contains("Missing.md"),
2551 "Warning should be for Missing.md, got: {}",
2552 result[0].message
2553 );
2554 }
2555
2556 #[test]
2557 fn test_no_warnings_without_base_path() {
2558 let rule = MD057ExistingRelativeLinks::new();
2559 let content = "[Link](missing.md)";
2560
2561 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2562 let result = rule.check(&ctx).unwrap();
2563 assert!(result.is_empty(), "Should have no warnings without base path");
2564 }
2565
2566 #[test]
2567 fn test_existing_and_missing_links() {
2568 let temp_dir = tempdir().unwrap();
2570 let base_path = temp_dir.path();
2571
2572 let exists_path = base_path.join("exists.md");
2574 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2575
2576 assert!(exists_path.exists(), "exists.md should exist for this test");
2578
2579 let content = r#"
2581# Test Document
2582
2583[Valid Link](exists.md)
2584[Invalid Link](missing.md)
2585[External Link](https://example.com)
2586[Media Link](image.jpg)
2587 "#;
2588
2589 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2591
2592 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2594 let result = rule.check(&ctx).unwrap();
2595
2596 assert_eq!(result.len(), 2);
2598 let messages: Vec<_> = result.iter().map(|w| w.message.as_str()).collect();
2599 assert!(messages.iter().any(|m| m.contains("missing.md")));
2600 assert!(messages.iter().any(|m| m.contains("image.jpg")));
2601 }
2602
2603 #[test]
2604 fn test_angle_bracket_links() {
2605 let temp_dir = tempdir().unwrap();
2607 let base_path = temp_dir.path();
2608
2609 let exists_path = base_path.join("exists.md");
2611 File::create(&exists_path).unwrap().write_all(b"# Test File").unwrap();
2612
2613 let content = r#"
2615# Test Document
2616
2617[Valid Link](<exists.md>)
2618[Invalid Link](<missing.md>)
2619[External Link](<https://example.com>)
2620 "#;
2621
2622 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
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 have exactly one warning");
2630 assert!(
2631 result[0].message.contains("missing.md"),
2632 "Warning should mention missing.md"
2633 );
2634 }
2635
2636 #[test]
2637 fn test_angle_bracket_links_with_parens() {
2638 let temp_dir = tempdir().unwrap();
2640 let base_path = temp_dir.path();
2641
2642 let app_dir = base_path.join("app");
2644 std::fs::create_dir(&app_dir).unwrap();
2645 let upload_dir = app_dir.join("(upload)");
2646 std::fs::create_dir(&upload_dir).unwrap();
2647 let page_file = upload_dir.join("page.tsx");
2648 File::create(&page_file)
2649 .unwrap()
2650 .write_all(b"export default function Page() {}")
2651 .unwrap();
2652
2653 let content = r#"
2655# Test Document with Paths Containing Parens
2656
2657[Upload Page](<app/(upload)/page.tsx>)
2658[Unix pipe](<https://en.wikipedia.org/wiki/Pipeline_(Unix)>)
2659[Missing](<app/(missing)/file.md>)
2660"#;
2661
2662 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2663
2664 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2665 let result = rule.check(&ctx).unwrap();
2666
2667 assert_eq!(
2669 result.len(),
2670 1,
2671 "Should have exactly one warning for missing file. Got: {result:?}"
2672 );
2673 assert!(
2674 result[0].message.contains("app/(missing)/file.md"),
2675 "Warning should mention app/(missing)/file.md"
2676 );
2677 }
2678
2679 #[test]
2680 fn test_balanced_parentheses_in_link_paths() {
2681 let temp_dir = tempdir().unwrap();
2686 let base_path = temp_dir.path();
2687
2688 let paren_file = base_path.join("file(inner).md");
2690 File::create(&paren_file)
2691 .unwrap()
2692 .write_all(b"# file(inner).md\n")
2693 .unwrap();
2694
2695 let folder = base_path.join("folder(inner)");
2697 std::fs::create_dir(&folder).unwrap();
2698 File::create(folder.join("file.md"))
2699 .unwrap()
2700 .write_all(b"# folder(inner)/file.md\n")
2701 .unwrap();
2702
2703 let content = r#"
2704# Test cases
2705
2706[File with parenthesis exists](file(inner).md)
2707[Folder with parenthesis exists](folder(inner)/file.md)
2708[Missing with parenthesis](missing(inner).md)
2709"#;
2710
2711 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2712
2713 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2714 let result = rule.check(&ctx).unwrap();
2715
2716 assert_eq!(
2719 result.len(),
2720 1,
2721 "Expected exactly one warning (the missing file). Got: {result:?}"
2722 );
2723 assert!(
2724 result[0].message.contains("missing(inner).md"),
2725 "Warning should name the full path `missing(inner).md`, got: {}",
2726 result[0].message
2727 );
2728 }
2729
2730 #[test]
2731 fn test_all_file_types_checked() {
2732 let temp_dir = tempdir().unwrap();
2734 let base_path = temp_dir.path();
2735
2736 let content = r#"
2738[Image Link](image.jpg)
2739[Video Link](video.mp4)
2740[Markdown Link](document.md)
2741[PDF Link](file.pdf)
2742"#;
2743
2744 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2745
2746 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2747 let result = rule.check(&ctx).unwrap();
2748
2749 assert_eq!(result.len(), 4, "Should have warnings for all missing files");
2751 }
2752
2753 #[test]
2754 fn test_code_span_detection() {
2755 let rule = MD057ExistingRelativeLinks::new();
2756
2757 let temp_dir = tempdir().unwrap();
2759 let base_path = temp_dir.path();
2760
2761 let rule = rule.with_path(base_path);
2762
2763 let content = "This is a [link](nonexistent.md) and `[not a link](not-checked.md)` in code.";
2765
2766 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2767 let result = rule.check(&ctx).unwrap();
2768
2769 assert_eq!(result.len(), 1, "Should only flag the real link");
2771 assert!(result[0].message.contains("nonexistent.md"));
2772 }
2773
2774 #[test]
2775 fn test_inline_code_spans() {
2776 let temp_dir = tempdir().unwrap();
2778 let base_path = temp_dir.path();
2779
2780 let content = r#"
2782# Test Document
2783
2784This is a normal link: [Link](missing.md)
2785
2786This is a code span with a link: `[Link](another-missing.md)`
2787
2788Some more text with `inline code [Link](yet-another-missing.md) embedded`.
2789
2790 "#;
2791
2792 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2794
2795 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2797 let result = rule.check(&ctx).unwrap();
2798
2799 assert_eq!(result.len(), 1, "Should have exactly one warning");
2801 assert!(
2802 result[0].message.contains("missing.md"),
2803 "Warning should be for missing.md"
2804 );
2805 assert!(
2806 !result.iter().any(|w| w.message.contains("another-missing.md")),
2807 "Should not warn about link in code span"
2808 );
2809 assert!(
2810 !result.iter().any(|w| w.message.contains("yet-another-missing.md")),
2811 "Should not warn about link in inline code"
2812 );
2813 }
2814
2815 #[test]
2816 fn test_extensionless_link_resolution() {
2817 let temp_dir = tempdir().unwrap();
2819 let base_path = temp_dir.path();
2820
2821 let page_path = base_path.join("page.md");
2823 File::create(&page_path).unwrap().write_all(b"# Page").unwrap();
2824
2825 let content = r#"
2827# Test Document
2828
2829[Link without extension](page)
2830[Link with extension](page.md)
2831[Missing link](nonexistent)
2832"#;
2833
2834 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
2835
2836 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2837 let result = rule.check(&ctx).unwrap();
2838
2839 assert_eq!(result.len(), 1, "Should only warn about nonexistent link");
2842 assert!(
2843 result[0].message.contains("nonexistent"),
2844 "Warning should be for 'nonexistent' not 'page'"
2845 );
2846 }
2847
2848 #[test]
2850 fn test_cross_file_scope() {
2851 let rule = MD057ExistingRelativeLinks::new();
2852 assert_eq!(rule.cross_file_scope(), CrossFileScope::Workspace);
2853 }
2854
2855 #[test]
2856 fn test_contribute_to_index_extracts_markdown_links() {
2857 let rule = MD057ExistingRelativeLinks::new();
2858 let content = r#"
2859# Document
2860
2861[Link to docs](./docs/guide.md)
2862[Link with fragment](./other.md#section)
2863[External link](https://example.com)
2864[Image link](image.png)
2865[Media file](video.mp4)
2866"#;
2867
2868 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2869 let mut index = FileIndex::new();
2870 rule.contribute_to_index(&ctx, &mut index);
2871
2872 assert_eq!(index.cross_file_links.len(), 2);
2874
2875 assert_eq!(index.cross_file_links[0].target_path, "./docs/guide.md");
2877 assert_eq!(index.cross_file_links[0].fragment, "");
2878
2879 assert_eq!(index.cross_file_links[1].target_path, "./other.md");
2881 assert_eq!(index.cross_file_links[1].fragment, "section");
2882 }
2883
2884 #[test]
2885 fn test_contribute_to_index_skips_external_and_anchors() {
2886 let rule = MD057ExistingRelativeLinks::new();
2887 let content = r#"
2888# Document
2889
2890[External](https://example.com)
2891[Another external](http://example.org)
2892[Fragment only](#section)
2893[FTP link](ftp://files.example.com)
2894[Mail link](mailto:test@example.com)
2895[WWW link](www.example.com)
2896"#;
2897
2898 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2899 let mut index = FileIndex::new();
2900 rule.contribute_to_index(&ctx, &mut index);
2901
2902 assert_eq!(index.cross_file_links.len(), 0);
2904 }
2905
2906 #[test]
2907 fn test_cross_file_check_valid_link() {
2908 use crate::workspace_index::WorkspaceIndex;
2909
2910 let rule = MD057ExistingRelativeLinks::new();
2911
2912 let mut workspace_index = WorkspaceIndex::new();
2914 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
2915
2916 let mut file_index = FileIndex::new();
2918 file_index.add_cross_file_link(CrossFileLinkIndex {
2919 target_path: "guide.md".to_string(),
2920 fragment: "".to_string(),
2921 line: 5,
2922 column: 1,
2923 origin: LinkOrigin::Body,
2924 });
2925
2926 let warnings = rule
2928 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2929 .unwrap();
2930
2931 assert!(warnings.is_empty());
2933 }
2934
2935 #[test]
2936 fn test_cross_file_check_missing_link() {
2937 use crate::workspace_index::WorkspaceIndex;
2940
2941 let rule = MD057ExistingRelativeLinks::new();
2942 let workspace_index = WorkspaceIndex::new();
2943
2944 let mut file_index = FileIndex::new();
2945 file_index.add_cross_file_link(CrossFileLinkIndex {
2946 target_path: "missing.md".to_string(),
2947 fragment: "".to_string(),
2948 line: 5,
2949 column: 1,
2950 origin: LinkOrigin::Body,
2951 });
2952
2953 let warnings = rule
2954 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
2955 .unwrap();
2956
2957 assert!(
2959 warnings.is_empty(),
2960 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
2961 );
2962 }
2963
2964 #[test]
2965 fn test_cross_file_check_parent_path() {
2966 use crate::workspace_index::WorkspaceIndex;
2967
2968 let rule = MD057ExistingRelativeLinks::new();
2969
2970 let mut workspace_index = WorkspaceIndex::new();
2972 workspace_index.insert_file(PathBuf::from("readme.md"), FileIndex::new());
2973
2974 let mut file_index = FileIndex::new();
2976 file_index.add_cross_file_link(CrossFileLinkIndex {
2977 target_path: "../readme.md".to_string(),
2978 fragment: "".to_string(),
2979 line: 5,
2980 column: 1,
2981 origin: LinkOrigin::Body,
2982 });
2983
2984 let warnings = rule
2986 .cross_file_check(Path::new("docs/guide.md"), &file_index, &workspace_index)
2987 .unwrap();
2988
2989 assert!(warnings.is_empty());
2991 }
2992
2993 #[test]
2994 fn test_cross_file_check_html_link_with_md_source() {
2995 use crate::workspace_index::WorkspaceIndex;
2998
2999 let rule = MD057ExistingRelativeLinks::new();
3000
3001 let mut workspace_index = WorkspaceIndex::new();
3003 workspace_index.insert_file(PathBuf::from("docs/guide.md"), FileIndex::new());
3004
3005 let mut file_index = FileIndex::new();
3007 file_index.add_cross_file_link(CrossFileLinkIndex {
3008 target_path: "guide.html".to_string(),
3009 fragment: "section".to_string(),
3010 line: 10,
3011 column: 5,
3012 origin: LinkOrigin::Body,
3013 });
3014
3015 let warnings = rule
3017 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3018 .unwrap();
3019
3020 assert!(
3022 warnings.is_empty(),
3023 "Expected no warnings for .html link with .md source, got: {warnings:?}"
3024 );
3025 }
3026
3027 #[test]
3028 fn test_cross_file_check_html_link_without_source() {
3029 use crate::workspace_index::WorkspaceIndex;
3033
3034 let rule = MD057ExistingRelativeLinks::new();
3035 let workspace_index = WorkspaceIndex::new();
3036
3037 let mut file_index = FileIndex::new();
3038 file_index.add_cross_file_link(CrossFileLinkIndex {
3039 target_path: "missing.html".to_string(),
3040 fragment: "".to_string(),
3041 line: 10,
3042 column: 5,
3043 origin: LinkOrigin::Body,
3044 });
3045
3046 let warnings = rule
3047 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3048 .unwrap();
3049
3050 assert!(
3052 warnings.is_empty(),
3053 "cross_file_check must not duplicate check()'s per-file warnings. Got: {warnings:?}"
3054 );
3055 }
3056
3057 #[test]
3058 fn test_normalize_path_function() {
3059 assert_eq!(
3061 normalize_relative_path(Path::new("docs/guide.md")),
3062 PathBuf::from("docs/guide.md")
3063 );
3064
3065 assert_eq!(
3067 normalize_relative_path(Path::new("./docs/guide.md")),
3068 PathBuf::from("docs/guide.md")
3069 );
3070
3071 assert_eq!(
3073 normalize_relative_path(Path::new("docs/sub/../guide.md")),
3074 PathBuf::from("docs/guide.md")
3075 );
3076
3077 assert_eq!(
3079 normalize_relative_path(Path::new("a/b/c/../../d.md")),
3080 PathBuf::from("a/d.md")
3081 );
3082 }
3083
3084 #[test]
3085 fn test_html_link_with_md_source() {
3086 let temp_dir = tempdir().unwrap();
3088 let base_path = temp_dir.path();
3089
3090 let md_file = base_path.join("guide.md");
3092 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3093
3094 let content = r#"
3095[Read the guide](guide.html)
3096[Also here](getting-started.html)
3097"#;
3098
3099 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3100 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3101 let result = rule.check(&ctx).unwrap();
3102
3103 assert_eq!(
3105 result.len(),
3106 1,
3107 "Should only warn about missing source. Got: {result:?}"
3108 );
3109 assert!(result[0].message.contains("getting-started.html"));
3110 }
3111
3112 #[test]
3113 fn test_htm_link_with_md_source() {
3114 let temp_dir = tempdir().unwrap();
3116 let base_path = temp_dir.path();
3117
3118 let md_file = base_path.join("page.md");
3119 File::create(&md_file).unwrap().write_all(b"# Page").unwrap();
3120
3121 let content = "[Page](page.htm)";
3122
3123 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3124 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3125 let result = rule.check(&ctx).unwrap();
3126
3127 assert!(
3128 result.is_empty(),
3129 "Should not warn when .md source exists for .htm link"
3130 );
3131 }
3132
3133 #[test]
3134 fn test_html_link_finds_various_markdown_extensions() {
3135 let temp_dir = tempdir().unwrap();
3137 let base_path = temp_dir.path();
3138
3139 File::create(base_path.join("doc.md")).unwrap();
3140 File::create(base_path.join("tutorial.mdx")).unwrap();
3141 File::create(base_path.join("guide.markdown")).unwrap();
3142
3143 let content = r#"
3144[Doc](doc.html)
3145[Tutorial](tutorial.html)
3146[Guide](guide.html)
3147"#;
3148
3149 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3150 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3151 let result = rule.check(&ctx).unwrap();
3152
3153 assert!(
3154 result.is_empty(),
3155 "Should find all markdown variants as source files. Got: {result:?}"
3156 );
3157 }
3158
3159 #[test]
3160 fn test_html_link_in_subdirectory() {
3161 let temp_dir = tempdir().unwrap();
3163 let base_path = temp_dir.path();
3164
3165 let docs_dir = base_path.join("docs");
3166 std::fs::create_dir(&docs_dir).unwrap();
3167 File::create(docs_dir.join("guide.md"))
3168 .unwrap()
3169 .write_all(b"# Guide")
3170 .unwrap();
3171
3172 let content = "[Guide](docs/guide.html)";
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!(result.is_empty(), "Should find markdown source in subdirectory");
3179 }
3180
3181 #[test]
3182 fn test_absolute_path_skipped_in_check() {
3183 let temp_dir = tempdir().unwrap();
3186 let base_path = temp_dir.path();
3187
3188 let content = r#"
3189# Test Document
3190
3191[Go Runtime](/pkg/runtime)
3192[Go Runtime with Fragment](/pkg/runtime#section)
3193[API Docs](/api/v1/users)
3194[Blog Post](/blog/2024/release.html)
3195[React Hook](/react/hooks/use-state.html)
3196"#;
3197
3198 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3199 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3200 let result = rule.check(&ctx).unwrap();
3201
3202 assert!(
3204 result.is_empty(),
3205 "Absolute paths should be skipped. Got warnings: {result:?}"
3206 );
3207 }
3208
3209 #[test]
3210 fn test_absolute_path_skipped_in_cross_file_check() {
3211 use crate::workspace_index::WorkspaceIndex;
3213
3214 let rule = MD057ExistingRelativeLinks::new();
3215
3216 let workspace_index = WorkspaceIndex::new();
3218
3219 let mut file_index = FileIndex::new();
3221 file_index.add_cross_file_link(CrossFileLinkIndex {
3222 target_path: "/pkg/runtime.md".to_string(),
3223 fragment: "".to_string(),
3224 line: 5,
3225 column: 1,
3226 origin: LinkOrigin::Body,
3227 });
3228 file_index.add_cross_file_link(CrossFileLinkIndex {
3229 target_path: "/api/v1/users.md".to_string(),
3230 fragment: "section".to_string(),
3231 line: 10,
3232 column: 1,
3233 origin: LinkOrigin::Body,
3234 });
3235
3236 let warnings = rule
3238 .cross_file_check(Path::new("docs/index.md"), &file_index, &workspace_index)
3239 .unwrap();
3240
3241 assert!(
3243 warnings.is_empty(),
3244 "Absolute paths should be skipped in cross_file_check. Got warnings: {warnings:?}"
3245 );
3246 }
3247
3248 #[test]
3249 fn test_protocol_relative_url_not_skipped() {
3250 let temp_dir = tempdir().unwrap();
3253 let base_path = temp_dir.path();
3254
3255 let content = r#"
3256# Test Document
3257
3258[External](//example.com/page)
3259[Another](//cdn.example.com/asset.js)
3260"#;
3261
3262 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3263 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3264 let result = rule.check(&ctx).unwrap();
3265
3266 assert!(
3268 result.is_empty(),
3269 "Protocol-relative URLs should be skipped. Got warnings: {result:?}"
3270 );
3271 }
3272
3273 #[test]
3274 fn test_email_addresses_skipped() {
3275 let temp_dir = tempdir().unwrap();
3278 let base_path = temp_dir.path();
3279
3280 let content = r#"
3281# Test Document
3282
3283[Contact](user@example.com)
3284[Steering](steering@kubernetes.io)
3285[Support](john.doe+filter@company.co.uk)
3286[User](user_name@sub.domain.com)
3287"#;
3288
3289 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3290 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3291 let result = rule.check(&ctx).unwrap();
3292
3293 assert!(
3295 result.is_empty(),
3296 "Email addresses should be skipped. Got warnings: {result:?}"
3297 );
3298 }
3299
3300 #[test]
3301 fn test_email_addresses_vs_file_paths() {
3302 let temp_dir = tempdir().unwrap();
3305 let base_path = temp_dir.path();
3306
3307 let content = r#"
3308# Test Document
3309
3310[Email](user@example.com) <!-- Should be skipped (email) -->
3311[Email2](steering@kubernetes.io) <!-- Should be skipped (email) -->
3312[Email3](user@file.md) <!-- Should be skipped (has @, treated as email) -->
3313"#;
3314
3315 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3316 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3317 let result = rule.check(&ctx).unwrap();
3318
3319 assert!(
3321 result.is_empty(),
3322 "All email addresses should be skipped. Got: {result:?}"
3323 );
3324 }
3325
3326 #[test]
3327 fn test_diagnostic_position_accuracy() {
3328 let temp_dir = tempdir().unwrap();
3330 let base_path = temp_dir.path();
3331
3332 let content = "prefix [text](missing.md) suffix";
3335 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3339 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3340 let result = rule.check(&ctx).unwrap();
3341
3342 assert_eq!(result.len(), 1, "Should have exactly one warning");
3343 assert_eq!(result[0].line, 1, "Should be on line 1");
3344 assert_eq!(result[0].column, 15, "Should point to start of URL 'missing.md'");
3345 assert_eq!(result[0].end_column, 25, "Should point past end of URL 'missing.md'");
3346 }
3347
3348 #[test]
3349 fn test_diagnostic_position_non_ascii_link() {
3350 let temp_dir = tempdir().unwrap();
3353 let base_path = temp_dir.path();
3354
3355 let content = "ä½ å¥½ä½ å¥½[ä½ å¥½](not-exist.md) bar";
3359
3360 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3361 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3362 let result = rule.check(&ctx).unwrap();
3363
3364 assert_eq!(result.len(), 1, "Should have exactly one warning");
3365 assert_eq!(result[0].line, 1, "Should be on line 1");
3366 assert_eq!(
3367 result[0].column, 10,
3368 "Column must be a character offset, not a byte offset"
3369 );
3370 assert_eq!(result[0].end_column, 22, "End column must be character-based");
3371 }
3372
3373 #[test]
3374 fn test_diagnostic_position_angle_brackets() {
3375 let temp_dir = tempdir().unwrap();
3377 let base_path = temp_dir.path();
3378
3379 let content = "[link](<missing.md>)";
3382 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!(result.len(), 1, "Should have exactly one warning");
3389 assert_eq!(result[0].line, 1, "Should be on line 1");
3390 assert_eq!(result[0].column, 9, "Should point to start of URL in angle brackets");
3391 }
3392
3393 #[test]
3394 fn test_diagnostic_position_multiline() {
3395 let temp_dir = tempdir().unwrap();
3397 let base_path = temp_dir.path();
3398
3399 let content = r#"# Title
3400Some text on line 2
3401[link on line 3](missing1.md)
3402More text
3403[link on line 5](missing2.md)"#;
3404
3405 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3406 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3407 let result = rule.check(&ctx).unwrap();
3408
3409 assert_eq!(result.len(), 2, "Should have two warnings");
3410
3411 assert_eq!(result[0].line, 3, "First warning should be on line 3");
3413 assert!(result[0].message.contains("missing1.md"));
3414
3415 assert_eq!(result[1].line, 5, "Second warning should be on line 5");
3417 assert!(result[1].message.contains("missing2.md"));
3418 }
3419
3420 #[test]
3421 fn test_diagnostic_position_with_spaces() {
3422 let temp_dir = tempdir().unwrap();
3424 let base_path = temp_dir.path();
3425
3426 let content = "[link]( missing.md )";
3427 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3432 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3433 let result = rule.check(&ctx).unwrap();
3434
3435 assert_eq!(result.len(), 1, "Should have exactly one warning");
3436 assert_eq!(result[0].column, 9, "Should point to URL after stripping spaces");
3438 }
3439
3440 #[test]
3441 fn test_diagnostic_position_image() {
3442 let temp_dir = tempdir().unwrap();
3444 let base_path = temp_dir.path();
3445
3446 let content = "";
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 let result = rule.check(&ctx).unwrap();
3451
3452 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3453 assert_eq!(result[0].line, 1);
3454 assert!(result[0].column > 0, "Should have valid column position");
3456 assert!(result[0].message.contains("missing.jpg"));
3457 }
3458
3459 #[test]
3460 fn test_diagnostic_position_non_ascii_image() {
3461 let temp_dir = tempdir().unwrap();
3463 let base_path = temp_dir.path();
3464
3465 let content = "ä½ å¥½ä½ å¥½";
3468
3469 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3470 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3471 let result = rule.check(&ctx).unwrap();
3472
3473 assert_eq!(result.len(), 1, "Should have exactly one warning for image");
3474 assert_eq!(result[0].line, 1, "Should be on line 1");
3475 assert_eq!(
3476 result[0].column, 5,
3477 "Column must be a character offset, not a byte offset"
3478 );
3479 assert!(result[0].message.contains("not-exist.png"));
3480 }
3481
3482 #[test]
3483 fn test_diagnostic_position_non_ascii_reference_def() {
3484 let temp_dir = tempdir().unwrap();
3488 let base_path = temp_dir.path();
3489
3490 let content = "[ä½ å¥½]: not-exist.md";
3493
3494 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3495 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3496 let result = rule.check(&ctx).unwrap();
3497
3498 assert_eq!(result.len(), 1, "Should have exactly one warning for reference def");
3499 assert_eq!(result[0].line, 1, "Should be on line 1");
3500 assert_eq!(
3501 result[0].column, 7,
3502 "Column must be a character offset, not a byte offset"
3503 );
3504 assert_eq!(result[0].end_column, 19, "End column must be character-based");
3505 }
3506
3507 #[test]
3508 fn test_wikilinks_skipped() {
3509 let temp_dir = tempdir().unwrap();
3512 let base_path = temp_dir.path();
3513
3514 let content = r#"# Test Document
3515
3516[[Microsoft#Windows OS]]
3517[[SomePage]]
3518[[Page With Spaces]]
3519[[path/to/page#section]]
3520[[page|Display Text]]
3521
3522This is a [real missing link](missing.md) that should be flagged.
3523"#;
3524
3525 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3526 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3527 let result = rule.check(&ctx).unwrap();
3528
3529 assert_eq!(
3531 result.len(),
3532 1,
3533 "Should only warn about missing.md, not wikilinks. Got: {result:?}"
3534 );
3535 assert!(
3536 result[0].message.contains("missing.md"),
3537 "Warning should be for missing.md, not wikilinks"
3538 );
3539 }
3540
3541 #[test]
3542 fn test_wiki_embeds_skipped() {
3543 let temp_dir = tempdir().unwrap();
3547 let base_path = temp_dir.path();
3548
3549 let content = r#"# Test Document
3550
3551![[diagram.png]]
3552![[subfolder/diagram.png]]
3553![[diagram.png|300]]
3554![[Some Note]]
3555
3556This is a [real missing link](missing.md) that should be flagged.
3557"#;
3558
3559 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3560 for flavor in [
3561 crate::config::MarkdownFlavor::Obsidian,
3562 crate::config::MarkdownFlavor::Standard,
3563 ] {
3564 let ctx = crate::lint_context::LintContext::new(content, flavor, None);
3565 let result = rule.check(&ctx).unwrap();
3566
3567 assert_eq!(
3568 result.len(),
3569 1,
3570 "{flavor:?}: should only warn about missing.md, not embeds. Got: {result:?}"
3571 );
3572 assert!(result[0].message.contains("missing.md"));
3573 }
3574 }
3575
3576 #[test]
3577 fn test_wikilinks_not_added_to_index() {
3578 let temp_dir = tempdir().unwrap();
3580 let base_path = temp_dir.path();
3581
3582 let content = r#"# Test Document
3583
3584[[Microsoft#Windows OS]]
3585[[SomePage#section]]
3586[Regular Link](other.md)
3587"#;
3588
3589 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3590 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3591
3592 let mut file_index = FileIndex::new();
3593 rule.contribute_to_index(&ctx, &mut file_index);
3594
3595 let cross_file_links = &file_index.cross_file_links;
3598 assert_eq!(
3599 cross_file_links.len(),
3600 1,
3601 "Only regular markdown links should be indexed, not wikilinks. Got: {cross_file_links:?}"
3602 );
3603 assert_eq!(file_index.cross_file_links[0].target_path, "other.md");
3604 }
3605
3606 #[test]
3607 fn test_reference_definition_missing_file() {
3608 let temp_dir = tempdir().unwrap();
3610 let base_path = temp_dir.path();
3611
3612 let content = r#"# Test Document
3613
3614[test]: ./missing.md
3615[example]: ./nonexistent.html
3616
3617Use [test] and [example] here.
3618"#;
3619
3620 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3621 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3622 let result = rule.check(&ctx).unwrap();
3623
3624 assert_eq!(
3626 result.len(),
3627 2,
3628 "Should have warnings for missing reference definition targets. Got: {result:?}"
3629 );
3630 assert!(
3631 result.iter().any(|w| w.message.contains("missing.md")),
3632 "Should warn about missing.md"
3633 );
3634 assert!(
3635 result.iter().any(|w| w.message.contains("nonexistent.html")),
3636 "Should warn about nonexistent.html"
3637 );
3638 }
3639
3640 #[test]
3641 fn test_reference_definition_existing_file() {
3642 let temp_dir = tempdir().unwrap();
3644 let base_path = temp_dir.path();
3645
3646 let exists_path = base_path.join("exists.md");
3648 File::create(&exists_path)
3649 .unwrap()
3650 .write_all(b"# Existing file")
3651 .unwrap();
3652
3653 let content = r#"# Test Document
3654
3655[test]: ./exists.md
3656
3657Use [test] here.
3658"#;
3659
3660 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3661 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3662 let result = rule.check(&ctx).unwrap();
3663
3664 assert!(
3666 result.is_empty(),
3667 "Should not warn about existing file. Got: {result:?}"
3668 );
3669 }
3670
3671 #[test]
3672 fn test_reference_definition_external_url_skipped() {
3673 let temp_dir = tempdir().unwrap();
3675 let base_path = temp_dir.path();
3676
3677 let content = r#"# Test Document
3678
3679[google]: https://google.com
3680[example]: http://example.org
3681[mail]: mailto:test@example.com
3682[ftp]: ftp://files.example.com
3683[local]: ./missing.md
3684
3685Use [google], [example], [mail], [ftp], [local] here.
3686"#;
3687
3688 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3689 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3690 let result = rule.check(&ctx).unwrap();
3691
3692 assert_eq!(
3694 result.len(),
3695 1,
3696 "Should only warn about local missing file. Got: {result:?}"
3697 );
3698 assert!(
3699 result[0].message.contains("missing.md"),
3700 "Warning should be for missing.md"
3701 );
3702 }
3703
3704 #[test]
3705 fn test_reference_definition_fragment_only_skipped() {
3706 let temp_dir = tempdir().unwrap();
3708 let base_path = temp_dir.path();
3709
3710 let content = r#"# Test Document
3711
3712[section]: #my-section
3713
3714Use [section] here.
3715"#;
3716
3717 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3718 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3719 let result = rule.check(&ctx).unwrap();
3720
3721 assert!(
3723 result.is_empty(),
3724 "Should not warn about fragment-only reference. Got: {result:?}"
3725 );
3726 }
3727
3728 #[test]
3729 fn test_reference_definition_column_position() {
3730 let temp_dir = tempdir().unwrap();
3732 let base_path = temp_dir.path();
3733
3734 let content = "[ref]: ./missing.md";
3737 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3741 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3742 let result = rule.check(&ctx).unwrap();
3743
3744 assert_eq!(result.len(), 1, "Should have exactly one warning");
3745 assert_eq!(result[0].line, 1, "Should be on line 1");
3746 assert_eq!(result[0].column, 8, "Should point to start of URL './missing.md'");
3747 }
3748
3749 #[test]
3750 fn test_reference_definition_html_with_md_source() {
3751 let temp_dir = tempdir().unwrap();
3753 let base_path = temp_dir.path();
3754
3755 let md_file = base_path.join("guide.md");
3757 File::create(&md_file).unwrap().write_all(b"# Guide").unwrap();
3758
3759 let content = r#"# Test Document
3760
3761[guide]: ./guide.html
3762[missing]: ./missing.html
3763
3764Use [guide] and [missing] here.
3765"#;
3766
3767 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3768 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3769 let result = rule.check(&ctx).unwrap();
3770
3771 assert_eq!(
3773 result.len(),
3774 1,
3775 "Should only warn about missing source. Got: {result:?}"
3776 );
3777 assert!(result[0].message.contains("missing.html"));
3778 }
3779
3780 #[test]
3781 fn test_reference_definition_url_encoded() {
3782 let temp_dir = tempdir().unwrap();
3784 let base_path = temp_dir.path();
3785
3786 let file_with_spaces = base_path.join("file with spaces.md");
3788 File::create(&file_with_spaces).unwrap().write_all(b"# Spaces").unwrap();
3789
3790 let content = r#"# Test Document
3791
3792[spaces]: ./file%20with%20spaces.md
3793[missing]: ./missing%20file.md
3794
3795Use [spaces] and [missing] here.
3796"#;
3797
3798 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3799 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3800 let result = rule.check(&ctx).unwrap();
3801
3802 assert_eq!(
3804 result.len(),
3805 1,
3806 "Should only warn about missing URL-encoded file. Got: {result:?}"
3807 );
3808 assert!(result[0].message.contains("missing%20file.md"));
3809 }
3810
3811 #[test]
3812 fn test_inline_and_reference_both_checked() {
3813 let temp_dir = tempdir().unwrap();
3815 let base_path = temp_dir.path();
3816
3817 let content = r#"# Test Document
3818
3819[inline link](./inline-missing.md)
3820[ref]: ./ref-missing.md
3821
3822Use [ref] here.
3823"#;
3824
3825 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3826 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3827 let result = rule.check(&ctx).unwrap();
3828
3829 assert_eq!(
3831 result.len(),
3832 2,
3833 "Should warn about both inline and reference links. Got: {result:?}"
3834 );
3835 assert!(
3836 result.iter().any(|w| w.message.contains("inline-missing.md")),
3837 "Should warn about inline-missing.md"
3838 );
3839 assert!(
3840 result.iter().any(|w| w.message.contains("ref-missing.md")),
3841 "Should warn about ref-missing.md"
3842 );
3843 }
3844
3845 #[test]
3846 fn test_footnote_definitions_not_flagged() {
3847 let rule = MD057ExistingRelativeLinks::default();
3850
3851 let content = r#"# Title
3852
3853A footnote[^1].
3854
3855[^1]: [link](https://www.google.com).
3856"#;
3857
3858 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3859 let result = rule.check(&ctx).unwrap();
3860
3861 assert!(
3862 result.is_empty(),
3863 "Footnote definitions should not trigger MD057 warnings. Got: {result:?}"
3864 );
3865 }
3866
3867 #[test]
3868 fn test_footnote_with_relative_link_inside() {
3869 let rule = MD057ExistingRelativeLinks::default();
3872
3873 let content = r#"# Title
3874
3875See the footnote[^1].
3876
3877[^1]: Check out [this file](./existing.md) for more info.
3878[^2]: Also see [missing](./does-not-exist.md).
3879"#;
3880
3881 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3882 let result = rule.check(&ctx).unwrap();
3883
3884 for warning in &result {
3889 assert!(
3890 !warning.message.contains("[this file]"),
3891 "Footnote content should not be treated as URL: {warning:?}"
3892 );
3893 assert!(
3894 !warning.message.contains("[missing]"),
3895 "Footnote content should not be treated as URL: {warning:?}"
3896 );
3897 }
3898 }
3899
3900 #[test]
3901 fn test_mixed_footnotes_and_reference_definitions() {
3902 let temp_dir = tempdir().unwrap();
3904 let base_path = temp_dir.path();
3905
3906 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3907
3908 let content = r#"# Title
3909
3910A footnote[^1] and a [ref link][myref].
3911
3912[^1]: This is a footnote with [link](https://example.com).
3913
3914[myref]: ./missing-file.md "This should be checked"
3915"#;
3916
3917 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3918 let result = rule.check(&ctx).unwrap();
3919
3920 assert_eq!(
3922 result.len(),
3923 1,
3924 "Should only warn about the regular reference definition. Got: {result:?}"
3925 );
3926 assert!(
3927 result[0].message.contains("missing-file.md"),
3928 "Should warn about missing-file.md in reference definition"
3929 );
3930 }
3931
3932 #[test]
3933 fn test_absolute_links_ignore_by_default() {
3934 let temp_dir = tempdir().unwrap();
3936 let base_path = temp_dir.path();
3937
3938 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
3939
3940 let content = r#"# Links
3941
3942[API docs](/api/v1/users)
3943[Blog post](/blog/2024/release.html)
3944
3945
3946[ref]: /docs/reference.md
3947"#;
3948
3949 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3950 let result = rule.check(&ctx).unwrap();
3951
3952 assert!(
3954 result.is_empty(),
3955 "Absolute links should be ignored by default. Got: {result:?}"
3956 );
3957 }
3958
3959 #[test]
3960 fn test_absolute_links_warn_config() {
3961 let temp_dir = tempdir().unwrap();
3963 let base_path = temp_dir.path();
3964
3965 let config = MD057Config {
3966 absolute_links: AbsoluteLinksOption::Warn,
3967 ..Default::default()
3968 };
3969 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
3970
3971 let content = r#"# Links
3972
3973[API docs](/api/v1/users)
3974[Blog post](/blog/2024/release.html)
3975"#;
3976
3977 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
3978 let result = rule.check(&ctx).unwrap();
3979
3980 assert_eq!(
3982 result.len(),
3983 2,
3984 "Should warn about both absolute links. Got: {result:?}"
3985 );
3986 assert!(
3987 result[0].message.contains("cannot be validated locally"),
3988 "Warning should explain why: {}",
3989 result[0].message
3990 );
3991 assert!(
3992 result[0].message.contains("/api/v1/users"),
3993 "Warning should include the link path"
3994 );
3995 }
3996
3997 #[test]
3998 fn test_absolute_links_warn_images() {
3999 let temp_dir = tempdir().unwrap();
4001 let base_path = temp_dir.path();
4002
4003 let config = MD057Config {
4004 absolute_links: AbsoluteLinksOption::Warn,
4005 ..Default::default()
4006 };
4007 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4008
4009 let content = r#"# Images
4010
4011
4012"#;
4013
4014 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4015 let result = rule.check(&ctx).unwrap();
4016
4017 assert_eq!(
4018 result.len(),
4019 1,
4020 "Should warn about absolute image path. Got: {result:?}"
4021 );
4022 assert!(
4023 result[0].message.contains("/assets/logo.png"),
4024 "Warning should include the image path"
4025 );
4026 }
4027
4028 #[test]
4029 fn test_absolute_links_warn_reference_definitions() {
4030 let temp_dir = tempdir().unwrap();
4032 let base_path = temp_dir.path();
4033
4034 let config = MD057Config {
4035 absolute_links: AbsoluteLinksOption::Warn,
4036 ..Default::default()
4037 };
4038 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4039
4040 let content = r#"# Reference
4041
4042See the [docs][ref].
4043
4044[ref]: /docs/reference.md
4045"#;
4046
4047 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4048 let result = rule.check(&ctx).unwrap();
4049
4050 assert_eq!(
4051 result.len(),
4052 1,
4053 "Should warn about absolute reference definition. Got: {result:?}"
4054 );
4055 assert!(
4056 result[0].message.contains("/docs/reference.md"),
4057 "Warning should include the reference path"
4058 );
4059 }
4060
4061 #[test]
4062 fn test_search_paths_inline_link() {
4063 let temp_dir = tempdir().unwrap();
4064 let base_path = temp_dir.path();
4065
4066 let assets_dir = base_path.join("assets");
4068 std::fs::create_dir_all(&assets_dir).unwrap();
4069 std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap();
4070
4071 let config = MD057Config {
4072 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4073 ..Default::default()
4074 };
4075 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4076
4077 let content = "# Test\n\n[Photo](photo.png)\n";
4078 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4079 let result = rule.check(&ctx).unwrap();
4080
4081 assert!(
4082 result.is_empty(),
4083 "Should find photo.png via search-paths. Got: {result:?}"
4084 );
4085 }
4086
4087 #[test]
4088 fn test_search_paths_image() {
4089 let temp_dir = tempdir().unwrap();
4090 let base_path = temp_dir.path();
4091
4092 let assets_dir = base_path.join("attachments");
4093 std::fs::create_dir_all(&assets_dir).unwrap();
4094 std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap();
4095
4096 let config = MD057Config {
4097 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4098 ..Default::default()
4099 };
4100 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4101
4102 let content = "# Test\n\n\n";
4103 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4104 let result = rule.check(&ctx).unwrap();
4105
4106 assert!(
4107 result.is_empty(),
4108 "Should find diagram.svg via search-paths. Got: {result:?}"
4109 );
4110 }
4111
4112 #[test]
4113 fn test_search_paths_reference_definition() {
4114 let temp_dir = tempdir().unwrap();
4115 let base_path = temp_dir.path();
4116
4117 let assets_dir = base_path.join("images");
4118 std::fs::create_dir_all(&assets_dir).unwrap();
4119 std::fs::write(assets_dir.join("logo.png"), "fake").unwrap();
4120
4121 let config = MD057Config {
4122 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4123 ..Default::default()
4124 };
4125 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4126
4127 let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n";
4128 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4129 let result = rule.check(&ctx).unwrap();
4130
4131 assert!(
4132 result.is_empty(),
4133 "Should find logo.png via search-paths in reference definition. Got: {result:?}"
4134 );
4135 }
4136
4137 #[test]
4138 fn test_search_paths_still_warns_when_truly_missing() {
4139 let temp_dir = tempdir().unwrap();
4140 let base_path = temp_dir.path();
4141
4142 let assets_dir = base_path.join("assets");
4143 std::fs::create_dir_all(&assets_dir).unwrap();
4144
4145 let config = MD057Config {
4146 search_paths: vec![assets_dir.to_string_lossy().into_owned()],
4147 ..Default::default()
4148 };
4149 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4150
4151 let content = "# Test\n\n\n";
4152 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4153 let result = rule.check(&ctx).unwrap();
4154
4155 assert_eq!(
4156 result.len(),
4157 1,
4158 "Should still warn when file doesn't exist in any search path. Got: {result:?}"
4159 );
4160 }
4161
4162 #[test]
4163 fn test_search_paths_nonexistent_directory() {
4164 let temp_dir = tempdir().unwrap();
4165 let base_path = temp_dir.path();
4166
4167 let config = MD057Config {
4168 search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()],
4169 ..Default::default()
4170 };
4171 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4172
4173 let content = "# Test\n\n\n";
4174 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4175 let result = rule.check(&ctx).unwrap();
4176
4177 assert_eq!(
4178 result.len(),
4179 1,
4180 "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}"
4181 );
4182 }
4183
4184 #[test]
4185 fn test_obsidian_attachment_folder_named() {
4186 let temp_dir = tempdir().unwrap();
4187 let vault = temp_dir.path().join("vault");
4188 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4189 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4190 std::fs::create_dir_all(vault.join("notes")).unwrap();
4191
4192 std::fs::write(
4193 vault.join(".obsidian/app.json"),
4194 r#"{"attachmentFolderPath": "Attachments"}"#,
4195 )
4196 .unwrap();
4197 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4198
4199 let notes_dir = vault.join("notes");
4200 let source_file = notes_dir.join("test.md");
4201 std::fs::write(&source_file, "# Test\n\n\n").unwrap();
4202
4203 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4204
4205 let content = "# Test\n\n\n";
4206 let ctx =
4207 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4208 let result = rule.check(&ctx).unwrap();
4209
4210 assert!(
4211 result.is_empty(),
4212 "Obsidian attachment folder should resolve photo.png. Got: {result:?}"
4213 );
4214 }
4215
4216 #[test]
4217 fn test_obsidian_attachment_same_folder_as_file() {
4218 let temp_dir = tempdir().unwrap();
4219 let vault = temp_dir.path().join("vault-rf");
4220 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4221 std::fs::create_dir_all(vault.join("notes")).unwrap();
4222
4223 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap();
4224
4225 let notes_dir = vault.join("notes");
4227 let source_file = notes_dir.join("test.md");
4228 std::fs::write(&source_file, "placeholder").unwrap();
4229 std::fs::write(notes_dir.join("photo.png"), "fake").unwrap();
4230
4231 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4232
4233 let content = "# Test\n\n\n";
4234 let ctx =
4235 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4236 let result = rule.check(&ctx).unwrap();
4237
4238 assert!(
4239 result.is_empty(),
4240 "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}"
4241 );
4242 }
4243
4244 #[test]
4245 fn test_obsidian_not_triggered_without_obsidian_flavor() {
4246 let temp_dir = tempdir().unwrap();
4247 let vault = temp_dir.path().join("vault-nf");
4248 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4249 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4250 std::fs::create_dir_all(vault.join("notes")).unwrap();
4251
4252 std::fs::write(
4253 vault.join(".obsidian/app.json"),
4254 r#"{"attachmentFolderPath": "Attachments"}"#,
4255 )
4256 .unwrap();
4257 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4258
4259 let notes_dir = vault.join("notes");
4260 let source_file = notes_dir.join("test.md");
4261 std::fs::write(&source_file, "placeholder").unwrap();
4262
4263 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4264
4265 let content = "# Test\n\n\n";
4266 let ctx =
4268 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4269 let result = rule.check(&ctx).unwrap();
4270
4271 assert_eq!(
4272 result.len(),
4273 1,
4274 "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}"
4275 );
4276 }
4277
4278 #[test]
4279 fn test_search_paths_combined_with_obsidian() {
4280 let temp_dir = tempdir().unwrap();
4281 let vault = temp_dir.path().join("vault-combo");
4282 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4283 std::fs::create_dir_all(vault.join("Attachments")).unwrap();
4284 std::fs::create_dir_all(vault.join("extra-assets")).unwrap();
4285 std::fs::create_dir_all(vault.join("notes")).unwrap();
4286
4287 std::fs::write(
4288 vault.join(".obsidian/app.json"),
4289 r#"{"attachmentFolderPath": "Attachments"}"#,
4290 )
4291 .unwrap();
4292 std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap();
4293 std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap();
4294
4295 let notes_dir = vault.join("notes");
4296 let source_file = notes_dir.join("test.md");
4297 std::fs::write(&source_file, "placeholder").unwrap();
4298
4299 let extra_assets_dir = vault.join("extra-assets");
4300 let config = MD057Config {
4301 search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()],
4302 ..Default::default()
4303 };
4304 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir);
4305
4306 let content = "# Test\n\n\n\n\n";
4308 let ctx =
4309 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4310 let result = rule.check(&ctx).unwrap();
4311
4312 assert!(
4313 result.is_empty(),
4314 "Both Obsidian attachment and search-paths should resolve. Got: {result:?}"
4315 );
4316 }
4317
4318 #[test]
4319 fn test_obsidian_attachment_subfolder_under_file() {
4320 let temp_dir = tempdir().unwrap();
4321 let vault = temp_dir.path().join("vault-sub");
4322 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4323 std::fs::create_dir_all(vault.join("notes/assets")).unwrap();
4324
4325 std::fs::write(
4326 vault.join(".obsidian/app.json"),
4327 r#"{"attachmentFolderPath": "./assets"}"#,
4328 )
4329 .unwrap();
4330 std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap();
4331
4332 let notes_dir = vault.join("notes");
4333 let source_file = notes_dir.join("test.md");
4334 std::fs::write(&source_file, "placeholder").unwrap();
4335
4336 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4337
4338 let content = "# Test\n\n\n";
4339 let ctx =
4340 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4341 let result = rule.check(&ctx).unwrap();
4342
4343 assert!(
4344 result.is_empty(),
4345 "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}"
4346 );
4347 }
4348
4349 #[test]
4350 fn test_obsidian_attachment_vault_root() {
4351 let temp_dir = tempdir().unwrap();
4352 let vault = temp_dir.path().join("vault-root");
4353 std::fs::create_dir_all(vault.join(".obsidian")).unwrap();
4354 std::fs::create_dir_all(vault.join("notes")).unwrap();
4355
4356 std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap();
4358 std::fs::write(vault.join("photo.png"), "fake").unwrap();
4359
4360 let notes_dir = vault.join("notes");
4361 let source_file = notes_dir.join("test.md");
4362 std::fs::write(&source_file, "placeholder").unwrap();
4363
4364 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir);
4365
4366 let content = "# Test\n\n\n";
4367 let ctx =
4368 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file));
4369 let result = rule.check(&ctx).unwrap();
4370
4371 assert!(
4372 result.is_empty(),
4373 "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}"
4374 );
4375 }
4376
4377 #[test]
4378 fn test_search_paths_multiple_directories() {
4379 let temp_dir = tempdir().unwrap();
4380 let base_path = temp_dir.path();
4381
4382 let dir_a = base_path.join("dir-a");
4383 let dir_b = base_path.join("dir-b");
4384 std::fs::create_dir_all(&dir_a).unwrap();
4385 std::fs::create_dir_all(&dir_b).unwrap();
4386 std::fs::write(dir_a.join("alpha.png"), "fake").unwrap();
4387 std::fs::write(dir_b.join("beta.png"), "fake").unwrap();
4388
4389 let config = MD057Config {
4390 search_paths: vec![
4391 dir_a.to_string_lossy().into_owned(),
4392 dir_b.to_string_lossy().into_owned(),
4393 ],
4394 ..Default::default()
4395 };
4396 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path);
4397
4398 let content = "# Test\n\n\n\n\n";
4399 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4400 let result = rule.check(&ctx).unwrap();
4401
4402 assert!(
4403 result.is_empty(),
4404 "Should find files across multiple search paths. Got: {result:?}"
4405 );
4406 }
4407
4408 #[test]
4417 fn test_cross_file_check_reports_nothing_even_for_a_broken_link() {
4418 use crate::workspace_index::{CrossFileLinkIndex, FileIndex, LinkOrigin, WorkspaceIndex};
4419
4420 let temp_dir = tempdir().unwrap();
4421 let base_path = temp_dir.path();
4422
4423 let file_path = base_path.join("README.md");
4424 let content = "# Readme\n\n[Guide](missing-guide.md)\n";
4425 std::fs::write(&file_path, content).unwrap();
4426
4427 let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(base_path);
4428
4429 let ctx = crate::lint_context::LintContext::new(
4430 content,
4431 crate::config::MarkdownFlavor::Standard,
4432 Some(file_path.clone()),
4433 );
4434 let per_file = rule.check(&ctx).unwrap();
4435 assert_eq!(
4436 per_file.len(),
4437 1,
4438 "control: check() is the pass that reports the broken link. Got: {per_file:?}"
4439 );
4440
4441 let mut file_index = FileIndex::default();
4442 file_index.cross_file_links.push(CrossFileLinkIndex {
4443 target_path: "missing-guide.md".to_string(),
4444 fragment: String::new(),
4445 line: 3,
4446 column: 1,
4447 origin: LinkOrigin::Body,
4448 });
4449
4450 let result = rule
4451 .cross_file_check(&file_path, &file_index, &WorkspaceIndex::new())
4452 .unwrap();
4453
4454 assert!(
4455 result.is_empty(),
4456 "cross_file_check must stay silent so the link is reported once, not twice. Got: {result:?}"
4457 );
4458 }
4459
4460 #[test]
4461 fn test_check_clears_stale_cache() {
4462 let temp_dir = tempdir().unwrap();
4465 let base_path = temp_dir.path();
4466
4467 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4468
4469 let phantom_path = base_path.join("phantom.md");
4471 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(phantom_path.clone(), true));
4472
4473 let content = "[phantom](phantom.md)\n";
4474 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4475 let warnings = rule.check(&ctx).unwrap();
4476
4477 assert_eq!(
4479 warnings.len(),
4480 1,
4481 "check() should report missing file after clearing stale cache. Got: {warnings:?}"
4482 );
4483 assert!(warnings[0].message.contains("phantom.md"));
4484 }
4485
4486 #[test]
4487 fn test_check_does_not_carry_over_cache_between_runs() {
4488 let temp_dir = tempdir().unwrap();
4490 let base_path = temp_dir.path();
4491
4492 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4493
4494 let content = "[missing](nonexistent.md)\n";
4495 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4496
4497 let warnings_1 = rule.check(&ctx).unwrap();
4499 assert_eq!(warnings_1.len(), 1, "First run should detect missing file");
4500
4501 let nonexistent_path = base_path.join("nonexistent.md");
4503 FILE_EXISTENCE_CACHE.with_borrow_mut(|cache| cache.insert(nonexistent_path.clone(), true));
4504
4505 let warnings_2 = rule.check(&ctx).unwrap();
4507 assert_eq!(
4508 warnings_2.len(),
4509 1,
4510 "Second check() run should still detect missing file after cache reset. Got: {warnings_2:?}"
4511 );
4512 }
4513
4514 fn anchor_resolutions_for(dir: &Path, name: &str, content: &str, config: MD057Config) -> (Vec<LintWarning>, usize) {
4517 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4518 let warnings = self_referential_links_tests::check_as_file(dir, name, content, config);
4519 (warnings, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4520 }
4521
4522 #[test]
4523 fn a_document_without_relative_destinations_never_resolves_its_anchor() {
4524 let temp_dir = tempdir().unwrap();
4525 let content = "[web](https://example.com/a.md) [top](#top) [abs](/docs/a.md)\n\
4526  \n\n\
4527 [ref]: https://example.com/r.md\n[aref]: /r.md\n";
4528 let (warnings, resolutions) =
4529 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4530 assert!(warnings.is_empty(), "{warnings:?}");
4531 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4532 }
4533
4534 #[test]
4535 fn a_document_resolves_its_anchor_once_for_every_relative_destination() {
4536 let temp_dir = tempdir().unwrap();
4537 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4538 let content = "[a](there.md) [b](missing.md) \n\n[d]: there.md\n";
4539 let (warnings, resolutions) =
4540 anchor_resolutions_for(temp_dir.path(), "doc.md", content, MD057Config::default());
4541 assert_eq!(warnings.len(), 2, "{warnings:?}");
4542 assert_eq!(resolutions, 1);
4543 }
4544
4545 #[test]
4546 fn an_absolute_link_resolved_from_the_docs_dir_resolves_the_anchor() {
4547 let temp_dir = tempdir().unwrap();
4548 std::fs::write(temp_dir.path().join("mkdocs.yml"), "site_name: x\n").unwrap();
4549 std::fs::create_dir(temp_dir.path().join("docs")).unwrap();
4550 let config = MD057Config {
4551 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4552 ..Default::default()
4553 };
4554 let (warnings, resolutions) =
4555 anchor_resolutions_for(&temp_dir.path().join("docs"), "doc.md", "[abs](/missing.md)\n", config);
4556 assert_eq!(warnings.len(), 1, "{warnings:?}");
4557 assert!(warnings[0].message.contains("does not exist"), "{warnings:?}");
4558 assert_eq!(resolutions, 1);
4559 }
4560
4561 #[cfg(feature = "blake3")]
4564 fn fingerprint_anchor_resolutions(dir: &Path, content: &str) -> (String, usize) {
4565 let source_file = dir.join("doc.md");
4566 std::fs::write(&source_file, content).unwrap();
4567 let rule = MD057ExistingRelativeLinks::default();
4568 let ctx = crate::lint_context::LintContext::new(
4569 content,
4570 crate::config::MarkdownFlavor::Standard,
4571 Some(source_file.clone()),
4572 );
4573 let mut file_index = FileIndex::new();
4574 rule.contribute_to_index(&ctx, &mut file_index);
4575 reset_file_existence_cache();
4577 let before = ANCHOR_RESOLUTIONS.with(std::cell::Cell::get);
4578 let fingerprint =
4579 rule.cache_dependency_fingerprint(&source_file, crate::config::MarkdownFlavor::Standard, &file_index);
4580 (fingerprint, ANCHOR_RESOLUTIONS.with(std::cell::Cell::get) - before)
4581 }
4582
4583 #[cfg(feature = "blake3")]
4584 #[test]
4585 fn a_fingerprint_over_absolute_destinations_never_resolves_the_anchor() {
4586 let temp_dir = tempdir().unwrap();
4587 let (_, resolutions) =
4588 fingerprint_anchor_resolutions(temp_dir.path(), "[a](/docs/a.md) \n\n[c]: /r.md\n");
4589 assert_eq!(resolutions, 0, "no destination here reads the document's directory");
4590 }
4591
4592 #[cfg(feature = "blake3")]
4593 #[test]
4594 fn a_fingerprint_over_relative_destinations_resolves_the_anchor_once_and_tracks_them() {
4595 let temp_dir = tempdir().unwrap();
4596 let content = "[a](there.md) [b](missing.md)\n\n[c]: there.md\n";
4597 let (missing, resolutions) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4598 assert_eq!(resolutions, 1);
4599 std::fs::write(temp_dir.path().join("there.md"), "# There\n").unwrap();
4600 let (present, _) = fingerprint_anchor_resolutions(temp_dir.path(), content);
4601 assert_ne!(missing, present, "creating a link target must change the fingerprint");
4602 }
4603
4604 #[test]
4610 fn test_no_duplicate_warnings_for_broken_relative_link() {
4611 use crate::workspace_index::WorkspaceIndex;
4612
4613 let temp_dir = tempdir().unwrap();
4614 let base_path = temp_dir.path();
4615
4616 let source_file = base_path.join("index.md");
4618 std::fs::write(&source_file, "[broken](does/not/exist.md)\n").unwrap();
4619
4620 let content = "[broken](does/not/exist.md)\n";
4621
4622 let rule = MD057ExistingRelativeLinks::new().with_path(base_path);
4623
4624 let ctx = crate::lint_context::LintContext::new(
4626 content,
4627 crate::config::MarkdownFlavor::Standard,
4628 Some(source_file.clone()),
4629 );
4630 let check_warnings = rule.check(&ctx).unwrap();
4631
4632 let mut file_index = FileIndex::new();
4634 rule.contribute_to_index(&ctx, &mut file_index);
4635 let workspace_index = WorkspaceIndex::new();
4636 let cross_warnings = rule
4637 .cross_file_check(&source_file, &file_index, &workspace_index)
4638 .unwrap();
4639
4640 let total = check_warnings.len() + cross_warnings.len();
4641 assert_eq!(
4642 total, 1,
4643 "Expected exactly 1 warning total across check() and cross_file_check(), got {total}: \
4644 check={check_warnings:?}, cross={cross_warnings:?}"
4645 );
4646 }
4647
4648 #[test]
4653 fn test_absolute_dir_link_accepted_relative_to_roots() {
4654 let temp_dir = tempdir().unwrap();
4655 let root = temp_dir.path();
4656
4657 let dir_d = root.join("d");
4659 std::fs::create_dir_all(&dir_d).unwrap();
4660 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4661
4662 let content = "\
4665[absolute dir](/d)\n\
4666[relative dir](d)\n\
4667[absolute file](/d/foo.md)\n\
4668[relative file](d/foo.md)\n";
4669
4670 let config = MD057Config {
4671 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4672 roots: vec![],
4673 ..Default::default()
4674 };
4675 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4676
4677 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4678 let result = rule.check(&ctx).unwrap();
4679
4680 assert!(
4681 result.is_empty(),
4682 "All four {{relative,absolute}} x {{file,dir}} links to existing targets must pass. Got: {result:?}"
4683 );
4684 }
4685
4686 #[test]
4692 fn test_absolute_directory_link_is_accepted_however_it_is_spelled() {
4693 let temp_dir = tempdir().unwrap();
4694 let root = temp_dir.path();
4695
4696 let dir_d = root.join("d");
4698 std::fs::create_dir_all(&dir_d).unwrap();
4699 std::fs::write(dir_d.join("foo.md"), "# Foo\n").unwrap();
4700
4701 let content = "\
4702[no slash](/d)\n\
4703[trailing slash](/d/)\n\
4704[trailing slash and fragment](/d/#intro)\n\
4705[relative](d/)\n";
4706
4707 let config = MD057Config {
4708 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4709 roots: vec![],
4710 ..Default::default()
4711 };
4712 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4713
4714 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4715 let result = rule.check(&ctx).unwrap();
4716
4717 assert!(
4718 result.is_empty(),
4719 "Every spelling of a link to an existing directory must agree. Got: {result:?}"
4720 );
4721 }
4722
4723 #[test]
4726 fn test_absolute_directory_link_to_a_missing_directory_is_still_reported() {
4727 let temp_dir = tempdir().unwrap();
4728 let root = temp_dir.path();
4729
4730 let content = "[gone](/nodir/)\n";
4731
4732 let config = MD057Config {
4733 absolute_links: AbsoluteLinksOption::RelativeToRoots,
4734 roots: vec![],
4735 ..Default::default()
4736 };
4737 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
4738
4739 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
4740 let result = rule.check(&ctx).unwrap();
4741
4742 assert_eq!(
4743 result.len(),
4744 1,
4745 "A directory link naming nothing on disk must still be reported. Got: {result:?}"
4746 );
4747 }
4748
4749 #[test]
4753 fn test_docs_dir_variant_still_enforces_index_md() {
4754 let temp_dir = tempdir().unwrap();
4755 let root = temp_dir.path();
4756
4757 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4759
4760 let docs_dir = root.join("docs");
4762 std::fs::create_dir_all(&docs_dir).unwrap();
4763 let section_dir = docs_dir.join("section");
4764 std::fs::create_dir_all(§ion_dir).unwrap();
4765 std::fs::write(section_dir.join("page.md"), "# Page\n").unwrap();
4766
4767 let source_file = docs_dir.join("index.md");
4769 std::fs::write(&source_file, "[sec](/section)\n").unwrap();
4770
4771 let config = MD057Config {
4772 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4773 ..Default::default()
4774 };
4775 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4776
4777 let content = "[sec](/section)\n";
4778 let ctx = crate::lint_context::LintContext::new(
4779 content,
4780 crate::config::MarkdownFlavor::Standard,
4781 Some(source_file.clone()),
4782 );
4783 let result = rule.check(&ctx).unwrap();
4784
4785 assert_eq!(
4787 result.len(),
4788 1,
4789 "MkDocs docs_dir variant must flag directory link without index.md. Got: {result:?}"
4790 );
4791 assert!(
4792 result[0].message.contains("index.md") || result[0].message.contains("section"),
4793 "Message should mention the directory or missing index.md: {}",
4794 result[0].message
4795 );
4796 }
4797
4798 #[test]
4803 fn test_docs_mode_requires_index_for_every_spelling_of_a_directory_link() {
4804 let temp_dir = tempdir().unwrap();
4805 let root = temp_dir.path();
4806 std::fs::write(root.join("mkdocs.yml"), "site_name: Test\ndocs_dir: docs\n").unwrap();
4807
4808 let docs_dir = root.join("docs");
4810 let guide_dir = docs_dir.join("guide");
4811 std::fs::create_dir_all(&guide_dir).unwrap();
4812 std::fs::write(guide_dir.join("page.md"), "# Page\n").unwrap();
4813
4814 let source_file = docs_dir.join("t.md");
4815 let content = "\
4816[no slash](/guide)\n\
4817[trailing slash](/guide/)\n\
4818[trailing slash and fragment](/guide/#intro)\n";
4819 std::fs::write(&source_file, content).unwrap();
4820
4821 let config = MD057Config {
4822 absolute_links: AbsoluteLinksOption::RelativeToDocs,
4823 ..Default::default()
4824 };
4825 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(&docs_dir);
4826 let ctx = crate::lint_context::LintContext::new(
4827 content,
4828 crate::config::MarkdownFlavor::Standard,
4829 Some(source_file.clone()),
4830 );
4831 let result = rule.check(&ctx).unwrap();
4832
4833 assert_eq!(
4834 result.len(),
4835 3,
4836 "Every directory link must be routed through the index.md check. Got: {result:?}"
4837 );
4838 for warning in &result {
4839 assert!(
4840 warning.message.contains("which has no index.md"),
4841 "The message must name the reason, not report the directory as missing: {}",
4842 warning.message
4843 );
4844 }
4845 }
4846}
4847
4848#[cfg(test)]
4849mod self_referential_links_tests {
4850 use super::*;
4851 use tempfile::tempdir;
4852
4853 pub(super) fn check_as_file(dir: &Path, name: &str, content: &str, config: MD057Config) -> Vec<LintWarning> {
4855 let source_file = dir.join(name);
4856 std::fs::write(&source_file, content).unwrap();
4857 let rule = MD057ExistingRelativeLinks::from_config_struct(config);
4858 let ctx =
4859 crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file));
4860 rule.check(&ctx).unwrap()
4861 }
4862
4863 fn enabled() -> MD057Config {
4864 MD057Config {
4865 self_referential_links: true,
4866 ..Default::default()
4867 }
4868 }
4869
4870 #[test]
4871 fn test_a_link_with_a_fragment_is_reduced_to_the_fragment() {
4872 let temp_dir = tempdir().unwrap();
4873 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
4874 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4875
4876 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4877 assert_eq!(
4878 result[0].message,
4879 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
4880 );
4881 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4882 assert_eq!(fix.replacement, "#level-2-heading");
4883 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4884 }
4885
4886 #[test]
4887 fn test_a_link_to_the_whole_file_is_reported_without_a_fix() {
4888 let temp_dir = tempdir().unwrap();
4889 let content = "# Title\n\nSee [this file](test.md).\n";
4890 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4891
4892 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4893 assert_eq!(result[0].message, "Relative link 'test.md' points to the file it is in");
4894 assert!(
4895 result[0].fix.is_none(),
4896 "Dropping the link would change the document, so there is no fix"
4897 );
4898 }
4899
4900 #[test]
4901 fn test_the_check_is_off_by_default() {
4902 let temp_dir = tempdir().unwrap();
4903 let content = "# Title\n\nSee [this file](test.md) and [the section](test.md#title).\n";
4904 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
4905
4906 assert!(result.is_empty(), "Off by default. Got: {result:?}");
4907 }
4908
4909 #[test]
4910 fn test_a_link_to_another_file_is_left_alone() {
4911 let temp_dir = tempdir().unwrap();
4912 std::fs::write(temp_dir.path().join("other.md"), "# Other\n").unwrap();
4913 let content = "# Title\n\nSee [the other file](other.md#other) and [a heading here](#title).\n";
4914 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4915
4916 assert!(result.is_empty(), "Neither link is self-referential. Got: {result:?}");
4917 }
4918
4919 #[test]
4920 fn test_a_self_link_written_with_traversal_reports_once() {
4921 let temp_dir = tempdir().unwrap();
4922 let sub_dir = temp_dir.path().join("sub");
4923 std::fs::create_dir_all(&sub_dir).unwrap();
4924
4925 let content = "# Title\n\nSee [the long way round](../sub/test.md).\n";
4926 let config = MD057Config {
4927 self_referential_links: true,
4928 compact_paths: true,
4929 ..Default::default()
4930 };
4931 let result = check_as_file(&sub_dir, "test.md", content, config);
4932
4933 assert_eq!(
4934 result.len(),
4935 1,
4936 "A compacted path would still be a link back to this file. Got: {result:?}"
4937 );
4938 assert_eq!(
4939 result[0].message,
4940 "Relative link '../sub/test.md' points to the file it is in"
4941 );
4942 }
4943
4944 #[test]
4945 fn test_compact_paths_still_reports_a_link_to_another_file() {
4946 let temp_dir = tempdir().unwrap();
4947 let sub_dir = temp_dir.path().join("sub");
4948 std::fs::create_dir_all(&sub_dir).unwrap();
4949 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
4950
4951 let content = "# Title\n\nSee [the long way round](../sub/other.md).\n";
4952 let config = MD057Config {
4953 self_referential_links: true,
4954 compact_paths: true,
4955 ..Default::default()
4956 };
4957 let result = check_as_file(&sub_dir, "test.md", content, config);
4958
4959 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
4960 assert_eq!(
4961 result[0].message,
4962 "Relative link '../sub/other.md' can be simplified to 'other.md'"
4963 );
4964 }
4965
4966 #[test]
4967 fn test_an_extensionless_self_link_resolves_the_same_way_the_existence_check_does() {
4968 let temp_dir = tempdir().unwrap();
4969 let content = "# Title\n\nSee [this file](test#title).\n";
4970 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4971
4972 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4973 assert_eq!(
4974 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
4975 Some("#title"),
4976 "Got: {result:?}"
4977 );
4978 }
4979
4980 #[test]
4981 fn test_a_reference_definition_pointing_at_its_own_file() {
4982 let temp_dir = tempdir().unwrap();
4983 let content = "# Title\n\nSee [the section][here].\n\n## Level 2 heading\n\n[here]: test.md#level-2-heading\n";
4984 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4985
4986 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4987 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
4988 assert_eq!(fix.replacement, "#level-2-heading");
4989 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
4990 }
4991
4992 #[test]
4993 fn test_a_reference_definition_whose_label_repeats_the_destination() {
4994 let temp_dir = tempdir().unwrap();
4995 let content = "# Title\n\nSee [the section][test.md#title].\n\n## Title\n\n[test.md#title]: test.md#title\n";
4996 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
4997
4998 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
4999 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5000 assert_eq!(fix.range.start, content.rfind("test.md#title").unwrap());
5003 let fixed = MD057ExistingRelativeLinks::from_config_struct(enabled())
5004 .fix(&crate::lint_context::LintContext::new(
5005 content,
5006 crate::config::MarkdownFlavor::Standard,
5007 Some(temp_dir.path().join("test.md")),
5008 ))
5009 .unwrap();
5010 assert!(fixed.contains("[test.md#title]: #title"), "Got: {fixed}");
5011 assert!(fixed.contains("[the section][test.md#title]"), "Got: {fixed}");
5012 }
5013
5014 #[test]
5015 fn test_a_self_link_resolved_through_a_search_path() {
5016 let temp_dir = tempdir().unwrap();
5017 let guide_dir = temp_dir.path().join("docs/guide");
5018 std::fs::create_dir_all(&guide_dir).unwrap();
5019 let config = MD057Config {
5020 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
5021 ..enabled()
5022 };
5023 let content = "# Title\n\nSee [this file](guide/test.md#title).\n";
5024 let result = check_as_file(&guide_dir, "test.md", content, config);
5025
5026 assert_eq!(
5027 result.len(),
5028 1,
5029 "A link the existence check accepts through a search path resolves to this same file. Got: {result:?}"
5030 );
5031 assert_eq!(
5032 result[0].fix.as_ref().map(|f| f.replacement.as_str()),
5033 Some("#title"),
5034 "Got: {result:?}"
5035 );
5036 }
5037
5038 #[test]
5039 fn test_a_target_next_to_the_document_outranks_a_search_path() {
5040 let temp_dir = tempdir().unwrap();
5041 let guide_dir = temp_dir.path().join("docs/guide");
5042 std::fs::create_dir_all(guide_dir.join("guide")).unwrap();
5043 std::fs::write(guide_dir.join("guide/test.md"), "# Other\n\n## Title\n").unwrap();
5044 let config = MD057Config {
5045 search_paths: vec![temp_dir.path().join("docs").to_string_lossy().into_owned()],
5046 ..enabled()
5047 };
5048 let content = "# Title\n\nSee [another file](guide/test.md#title).\n";
5049 let result = check_as_file(&guide_dir, "test.md", content, config);
5050
5051 assert!(
5052 result.is_empty(),
5053 "The link resolves to guide/guide/test.md, so the search path never answers for it. Got: {result:?}"
5054 );
5055 }
5056
5057 #[test]
5058 fn test_an_image_pointing_at_its_own_file_is_not_reported() {
5059 let temp_dir = tempdir().unwrap();
5060 let content = "# Title\n\n\n";
5061 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
5062
5063 assert!(
5064 result.is_empty(),
5065 "An image is not a link the reader follows. Got: {result:?}"
5066 );
5067 }
5068
5069 #[test]
5070 fn test_a_query_string_is_reported_without_a_suggestion() {
5071 let temp_dir = tempdir().unwrap();
5072 let content = "# Title\n\nSee [this file](test.md?raw=true#title).\n";
5073 let result = check_as_file(temp_dir.path(), "test.md", content, enabled());
5074
5075 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5076 assert!(
5077 result[0].fix.is_none(),
5078 "A query does not survive losing its path. Got: {result:?}"
5079 );
5080 }
5081
5082 #[test]
5083 fn test_fix_rewrites_the_document_and_settles() {
5084 let temp_dir = tempdir().unwrap();
5085 let content = "# Title\n\nSee [the section](test.md#level-2-heading).\n\n## Level 2 heading\n";
5086 let source_file = temp_dir.path().join("test.md");
5087 std::fs::write(&source_file, content).unwrap();
5088
5089 let rule = MD057ExistingRelativeLinks::from_config_struct(enabled());
5090 let ctx = crate::lint_context::LintContext::new(
5091 content,
5092 crate::config::MarkdownFlavor::Standard,
5093 Some(source_file.clone()),
5094 );
5095 let fixed = rule.fix(&ctx).unwrap();
5096 assert_eq!(
5097 fixed,
5098 "# Title\n\nSee [the section](#level-2-heading).\n\n## Level 2 heading\n"
5099 );
5100
5101 let refixed =
5102 crate::lint_context::LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, Some(source_file));
5103 assert_eq!(rule.fix(&refixed).unwrap(), fixed, "The fix converges in one pass");
5104 }
5105
5106 #[test]
5107 fn test_the_rule_reports_no_fixes_when_neither_rewriting_option_is_on() {
5108 let unfixable = MD057ExistingRelativeLinks::default();
5109 assert_eq!(unfixable.fix_capability(), FixCapability::Unfixable);
5110
5111 let fixable = MD057ExistingRelativeLinks::from_config_struct(enabled());
5112 assert_eq!(fixable.fix_capability(), FixCapability::ConditionallyFixable);
5113 }
5114
5115 #[test]
5116 fn test_the_option_is_read_from_kebab_and_snake_case() {
5117 let kebab: MD057Config = toml::from_str("self-referential-links = true").unwrap();
5118 assert!(kebab.self_referential_links);
5119
5120 let snake: MD057Config = toml::from_str("self_referential_links = true").unwrap();
5121 assert!(snake.self_referential_links);
5122 }
5123
5124 fn front_matter_checked() -> MD057Config {
5125 MD057Config {
5126 check_frontmatter: true,
5127 ..Default::default()
5128 }
5129 }
5130
5131 #[test]
5132 fn test_a_broken_frontmatter_path_is_reported_when_enabled() {
5133 let temp_dir = tempdir().unwrap();
5134 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5135 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5136
5137 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5138 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5139 assert_eq!(result[0].line, 2);
5140 assert_eq!(result[0].column, 11, "The warning points at the value, not the key");
5141 assert_eq!(result[0].end_column, 23);
5142 }
5143
5144 #[test]
5145 fn test_frontmatter_paths_are_not_checked_by_default() {
5146 let temp_dir = tempdir().unwrap();
5147 let content = "---\ntemplate: ./missing.md\n---\n\n# Title\n";
5148 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5149
5150 assert!(
5151 result.is_empty(),
5152 "Frontmatter is only checked on request. Got: {result:?}"
5153 );
5154 }
5155
5156 #[test]
5157 fn test_an_existing_frontmatter_path_is_not_reported() {
5158 let temp_dir = tempdir().unwrap();
5159 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5160 let content = "---\ntemplate: ./guide.md\nfallback: ./gone.md\n---\n\n# Title\n";
5161 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5162
5163 assert_eq!(result.len(), 1, "Only the missing target is reported. Got: {result:?}");
5164 assert_eq!(result[0].line, 3);
5165 }
5166
5167 #[test]
5168 fn test_an_ignored_frontmatter_field_is_not_checked() {
5169 let temp_dir = tempdir().unwrap();
5170 let content = "---\nimage: ./missing.png\ntemplate: ./missing.md\n---\n\n# Title\n";
5171 let config = MD057Config {
5172 check_frontmatter: true,
5173 ignore_frontmatter_fields: vec!["Image".to_string()],
5174 ..Default::default()
5175 };
5176 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5177
5178 assert_eq!(
5179 result.len(),
5180 1,
5181 "The ignored field is skipped and the other is not. Got: {result:?}"
5182 );
5183 assert_eq!(result[0].line, 3);
5184 }
5185
5186 #[test]
5187 fn test_an_external_frontmatter_url_is_not_reported() {
5188 let temp_dir = tempdir().unwrap();
5189 let content = "---\ncanonical: https://example.com/docs/guide.md\n---\n\n# Title\n";
5190 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5191
5192 assert!(
5193 result.is_empty(),
5194 "An external URL has no local target. Got: {result:?}"
5195 );
5196 }
5197
5198 #[test]
5199 fn test_a_frontmatter_fragment_is_left_to_md051() {
5200 let temp_dir = tempdir().unwrap();
5201 let content = "---\nanchor: '#missing'\n---\n\n# Title\n";
5202 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5203
5204 assert!(
5205 result.is_empty(),
5206 "A fragment names a heading, not a file. Got: {result:?}"
5207 );
5208 }
5209
5210 #[test]
5211 fn test_an_absolute_frontmatter_path_follows_the_absolute_links_option() {
5212 let temp_dir = tempdir().unwrap();
5213 let content = "---\ntemplate: /docs/guide.md\n---\n\n# Title\n";
5214
5215 let ignored = check_as_file(temp_dir.path(), "ignored.md", content, front_matter_checked());
5216 assert!(
5217 ignored.is_empty(),
5218 "Absolute paths are ignored by default. Got: {ignored:?}"
5219 );
5220
5221 let warning_config = MD057Config {
5222 check_frontmatter: true,
5223 absolute_links: AbsoluteLinksOption::Warn,
5224 ..Default::default()
5225 };
5226 let warned = check_as_file(temp_dir.path(), "warned.md", content, warning_config);
5227 assert_eq!(warned.len(), 1, "Expected one warning. Got: {warned:?}");
5228 assert_eq!(
5229 warned[0].message,
5230 "Absolute link '/docs/guide.md' cannot be validated locally"
5231 );
5232 }
5233
5234 #[test]
5235 fn test_a_frontmatter_path_carrying_a_query_is_checked() {
5236 let temp_dir = tempdir().unwrap();
5237 std::fs::write(temp_dir.path().join("guide.md"), "# Guide\n").unwrap();
5238 let content = "---\ntemplate: docs/missing.md?raw=true\nfallback: guide.md?raw=true\n---\n\n# Title\n";
5239 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5240
5241 assert_eq!(
5242 result.len(),
5243 1,
5244 "A query names no file, so only the missing target is reported. Got: {result:?}"
5245 );
5246 assert_eq!(result[0].line, 2);
5247 assert_eq!(
5248 result[0].message,
5249 "Relative link 'docs/missing.md?raw=true' does not exist"
5250 );
5251 }
5252
5253 #[test]
5254 fn test_prose_in_frontmatter_is_not_read_as_a_path() {
5255 let temp_dir = tempdir().unwrap();
5256 let content = "---\ntitle: Node.js\nversion: 1.2.3\ntags: ci/cd\ndate: 2026-07-31\n---\n\n# Title\n";
5257 let result = check_as_file(temp_dir.path(), "test.md", content, front_matter_checked());
5258
5259 assert!(
5260 result.is_empty(),
5261 "Only path-shaped values are destinations. Got: {result:?}"
5262 );
5263 }
5264}
5265
5266#[cfg(test)]
5271mod wrapped_link_text_tests {
5272 use super::self_referential_links_tests::check_as_file;
5273 use super::*;
5274 use tempfile::tempdir;
5275
5276 #[test]
5277 fn test_a_wrapped_link_in_a_list_item_is_reported() {
5278 let temp_dir = tempdir().unwrap();
5279 let content = "- Items reimbursable by the various [one-off\n expense](does-not-exist-anywhere)\n budgets.\n";
5280 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5281
5282 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5283 assert_eq!(
5284 result[0].message,
5285 "Relative link 'does-not-exist-anywhere' does not exist"
5286 );
5287 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5288 assert_eq!(result[0].end_line, 2);
5289 assert_eq!(result[0].column, 12);
5292 assert_eq!(result[0].end_column, 35);
5293 }
5294
5295 #[test]
5296 fn test_a_wrapped_link_in_a_paragraph_is_reported() {
5297 let temp_dir = tempdir().unwrap();
5298 let content = "Paragraph with [wrapped\ntext](also-missing) here.\n";
5299 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5300
5301 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5302 assert_eq!(result[0].message, "Relative link 'also-missing' does not exist");
5303 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5304 assert_eq!(result[0].column, 7);
5307 assert_eq!(result[0].end_column, 19);
5308 }
5309
5310 #[test]
5311 fn test_a_wrapped_link_carrying_a_title_is_reported() {
5312 let temp_dir = tempdir().unwrap();
5313 let content = "[wrapped\ntext](missing-titled.md \"t\")\n";
5314 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5315
5316 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5317 assert_eq!(result[0].message, "Relative link 'missing-titled.md' does not exist");
5318 assert_eq!(result[0].line, 2);
5319 assert_eq!(result[0].column, 7);
5322 assert_eq!(result[0].end_column, 24);
5323 }
5324
5325 #[test]
5326 fn test_a_wrapped_link_to_an_existing_file_is_left_alone() {
5327 let temp_dir = tempdir().unwrap();
5328 std::fs::write(temp_dir.path().join("target.md"), "# Target\n").unwrap();
5329 let content = "Paragraph with [wrapped\ntext](target.md) here.\n";
5330 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5331
5332 assert!(result.is_empty(), "The target exists. Got: {result:?}");
5333 }
5334
5335 #[test]
5336 fn test_a_wrapped_link_target_reaches_the_dependency_index() {
5337 let rule = MD057ExistingRelativeLinks::new();
5338 let content = "Paragraph with [wrapped\ntext](./docs/guide.md) here.\n";
5339
5340 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5341 let mut index = FileIndex::new();
5342 rule.contribute_to_index(&ctx, &mut index);
5343
5344 let targets: Vec<&str> = index
5345 .md057_link_targets
5346 .iter()
5347 .map(|target| target.target.as_str())
5348 .collect();
5349 assert_eq!(
5350 targets,
5351 vec!["./docs/guide.md"],
5352 "The index must record the target so a cached verdict is invalidated when the file appears"
5353 );
5354 }
5355
5356 #[test]
5357 fn test_a_wrapped_link_is_compacted_over_the_right_bytes() {
5358 let temp_dir = tempdir().unwrap();
5359 let sub_dir = temp_dir.path().join("sub");
5360 std::fs::create_dir_all(&sub_dir).unwrap();
5361 std::fs::write(sub_dir.join("other.md"), "# Other\n").unwrap();
5362
5363 let content = "See [the long way\nround](../sub/other.md#part) here.\n";
5364 let config = MD057Config {
5365 compact_paths: true,
5366 ..Default::default()
5367 };
5368 let result = check_as_file(&sub_dir, "test.md", content, config);
5369
5370 assert_eq!(result.len(), 1, "Expected the compaction warning. Got: {result:?}");
5371 assert_eq!(
5372 result[0].message,
5373 "Relative link '../sub/other.md#part' can be simplified to 'other.md#part'"
5374 );
5375 assert_eq!(result[0].line, 2);
5376 let fix = result[0].fix.as_ref().expect("a compaction is fixable");
5377 assert_eq!(&content[fix.range.clone()], "../sub/other.md#part");
5378 assert_eq!(fix.replacement, "other.md#part");
5379 }
5380
5381 #[test]
5382 fn test_a_wrapped_self_link_is_reduced_over_the_right_bytes() {
5383 let temp_dir = tempdir().unwrap();
5384 let content = "# Title\n\nSee [the section\nbelow](test.md#level-2-heading).\n\n## Level 2 heading\n";
5385 let config = MD057Config {
5386 self_referential_links: true,
5387 ..Default::default()
5388 };
5389 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5390
5391 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5392 assert_eq!(
5393 result[0].message,
5394 "Relative link 'test.md#level-2-heading' points to the file it is in and can be simplified to '#level-2-heading'"
5395 );
5396 assert_eq!(result[0].line, 4);
5397 let fix = result[0].fix.as_ref().expect("the fragment form is fixable");
5398 assert_eq!(&content[fix.range.clone()], "test.md#level-2-heading");
5399 assert_eq!(fix.replacement, "#level-2-heading");
5400 }
5401}
5402
5403#[cfg(test)]
5409mod destination_boundary_tests {
5410 use super::self_referential_links_tests::check_as_file;
5411 use super::*;
5412 use tempfile::tempdir;
5413
5414 fn compacting() -> MD057Config {
5415 MD057Config {
5416 compact_paths: true,
5417 ..Default::default()
5418 }
5419 }
5420
5421 #[test]
5425 fn test_a_title_spelling_a_bracket_paren_is_not_the_destination() {
5426 let temp_dir = tempdir().unwrap();
5427 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5428 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5429
5430 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5431
5432 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5433 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5434 assert_eq!(result[0].line, 1);
5435 assert_eq!(result[0].column, 13);
5437 assert_eq!(result[0].end_column, 23);
5438 assert!(result[0].fix.is_none(), "A missing target carries no fix");
5439 }
5440
5441 #[test]
5444 fn test_compaction_never_rewrites_a_title() {
5445 let temp_dir = tempdir().unwrap();
5446 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5447 let content = "[literal `](missing.md \"See ](./existing.md)\")\n";
5448
5449 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5450
5451 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5452 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5453 assert_eq!(result[0].column, 13);
5454 assert!(
5455 result.iter().all(|warning| warning.fix.is_none()),
5456 "Nothing in this document is rewritable. Got: {result:?}"
5457 );
5458 }
5459
5460 #[test]
5463 fn test_a_nested_bracket_does_not_close_the_label() {
5464 let temp_dir = tempdir().unwrap();
5465 let content = "[see [note]](./missing.md)\n";
5466
5467 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5468
5469 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5470 assert_eq!(result[0].message, "Relative link './missing.md' does not exist");
5471 assert_eq!(result[0].column, 14);
5473 assert_eq!(result[0].end_column, 26);
5474 }
5475
5476 #[test]
5480 fn test_an_autolink_carries_no_destination() {
5481 let temp_dir = tempdir().unwrap();
5482 std::fs::write(temp_dir.path().join("existing.md"), "# Existing\n").unwrap();
5483 let content = "<https://example.com/](./existing.md)> [ok](existing.md)\n";
5484
5485 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5486
5487 assert!(result.is_empty(), "An autolink is not a relative link. Got: {result:?}");
5488 }
5489
5490 #[test]
5492 fn test_an_email_autolink_carries_no_destination() {
5493 let temp_dir = tempdir().unwrap();
5494 let content = "<user@example.com>\n";
5495
5496 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5497
5498 assert!(
5499 result.is_empty(),
5500 "An email autolink is not a relative link. Got: {result:?}"
5501 );
5502 }
5503
5504 #[test]
5509 fn test_a_code_span_spelling_a_bracket_paren_is_not_the_destination() {
5510 let temp_dir = tempdir().unwrap();
5511 std::fs::write(temp_dir.path().join("exists.md"), "# Exists\n").unwrap();
5512 let content = "[``a\n](./exists.md)``](exists.md)\n";
5513
5514 let result = check_as_file(temp_dir.path(), "test.md", content, compacting());
5515
5516 assert!(
5517 result.is_empty(),
5518 "Both destinations exist, so nothing is reported. Got: {result:?}"
5519 );
5520 assert!(
5521 result.iter().all(|warning| warning.fix.is_none()),
5522 "Nothing here is rewritable, the code span least of all. Got: {result:?}"
5523 );
5524 }
5525
5526 #[test]
5530 fn test_a_bracket_in_an_html_attribute_does_not_open_a_label() {
5531 let temp_dir = tempdir().unwrap();
5532 let content = "[<i title=\"[\">text</i>](missing.md)\n";
5533
5534 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5535
5536 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5537 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5538 assert_eq!(result[0].line, 1);
5539 assert_eq!(result[0].column, 25);
5541 assert_eq!(result[0].end_column, 35);
5542 }
5543
5544 #[test]
5548 fn test_a_link_inside_an_image_description_is_not_a_link() {
5549 let temp_dir = tempdir().unwrap();
5550 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5551 let content = "](exists.png)\n";
5552
5553 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5554
5555 assert!(
5556 result.is_empty(),
5557 "The description of an image carries no link. Got: {result:?}"
5558 );
5559 }
5560
5561 #[test]
5565 fn test_a_link_inside_literal_brackets_is_still_a_link() {
5566 let temp_dir = tempdir().unwrap();
5567 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5568 let content = "[outer [inner](missing2.md)](exists.png)\n";
5569
5570 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5571
5572 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5573 assert_eq!(result[0].message, "Relative link 'missing2.md' does not exist");
5574 assert_eq!(result[0].line, 1);
5575 assert_eq!(result[0].column, 16);
5577 }
5578
5579 #[test]
5582 fn test_an_image_of_its_own_is_still_reported() {
5583 let temp_dir = tempdir().unwrap();
5584 let content = "\n";
5585
5586 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5587
5588 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5589 assert_eq!(result[0].message, "Relative link 'missing.png' does not exist");
5590 assert_eq!(result[0].column, 1);
5591 }
5592
5593 #[test]
5596 fn test_an_image_used_as_link_text_leaves_the_link_readable() {
5597 let temp_dir = tempdir().unwrap();
5598 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5599 let content = "[](missing.md)\n";
5600
5601 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5602
5603 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5604 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5605 assert_eq!(result[0].column, 22);
5607 }
5608
5609 #[test]
5613 fn test_a_link_wrapping_a_collapsed_reference_image_is_still_read() {
5614 let temp_dir = tempdir().unwrap();
5615 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5616 let content = "[![alt][]](missing.md)\n\n[alt]: exists.png\n";
5617
5618 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5619
5620 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5621 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5622 assert_eq!(result[0].line, 1);
5623 assert_eq!(result[0].column, 12);
5625 assert_eq!(result[0].end_column, 22);
5626 }
5627
5628 #[test]
5633 fn test_a_link_inside_an_undefined_reference_image_is_a_link() {
5634 for content in [
5635 "][nodef]\n",
5636 "][]\n",
5637 "]\n",
5638 ] {
5639 let temp_dir = tempdir().unwrap();
5640
5641 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5642
5643 assert_eq!(result.len(), 1, "Expected one warning for {content:?}. Got: {result:?}");
5644 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5645 assert_eq!(result[0].column, 11, "Wrong column for {content:?}");
5647 }
5648 }
5649
5650 #[test]
5653 fn test_an_image_with_an_empty_destination_is_still_an_image() {
5654 let temp_dir = tempdir().unwrap();
5655 let content = "]()\n";
5656
5657 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5658
5659 assert!(
5660 result.is_empty(),
5661 "An image with no destination is still an image. Got: {result:?}"
5662 );
5663 }
5664
5665 #[test]
5669 fn test_a_link_after_an_inner_image_is_still_inside_the_outer_image() {
5670 let temp_dir = tempdir().unwrap();
5671 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5672 let content = " [link](missing.md)](exists.png)\n";
5673
5674 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5675
5676 assert!(
5677 result.is_empty(),
5678 "The link sits inside the outer image's description. Got: {result:?}"
5679 );
5680 }
5681
5682 #[test]
5685 fn test_a_link_after_an_image_is_a_link() {
5686 let temp_dir = tempdir().unwrap();
5687 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5688 let content = " [link](missing.md)\n";
5689
5690 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5691
5692 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5693 assert_eq!(result[0].message, "Relative link 'missing.md' does not exist");
5694 assert_eq!(result[0].column, 29);
5696 }
5697
5698 #[test]
5700 fn test_a_link_inside_a_defined_reference_image_is_not_a_link() {
5701 let temp_dir = tempdir().unwrap();
5702 std::fs::write(temp_dir.path().join("exists.png"), "x").unwrap();
5703 let content = "][def]\n\n[def]: exists.png\n";
5704
5705 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5706
5707 assert!(
5708 result.is_empty(),
5709 "The description of a resolved reference image carries no link. Got: {result:?}"
5710 );
5711 }
5712
5713 #[test]
5716 fn test_a_destination_on_the_next_line_is_reported_there() {
5717 let temp_dir = tempdir().unwrap();
5718 let content = "[a](\n ./gone.md)\n";
5719
5720 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5721
5722 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5723 assert_eq!(result[0].message, "Relative link './gone.md' does not exist");
5724 assert_eq!(result[0].line, 2, "The destination sits on the second line");
5725 assert_eq!(result[0].end_line, 2);
5726 assert_eq!(result[0].column, 3);
5728 assert_eq!(result[0].end_column, 12);
5729 }
5730}
5731
5732#[cfg(test)]
5740mod exact_case_tests {
5741 use super::self_referential_links_tests::check_as_file;
5742 use super::*;
5743 use tempfile::tempdir;
5744
5745 fn volume_folds_case(dir: &Path) -> bool {
5750 let probe = dir.join("case-fold-probe.tmp");
5751 std::fs::write(&probe, "").unwrap();
5752 let folded = dir.join("CASE-FOLD-PROBE.TMP").exists();
5753 std::fs::remove_file(&probe).unwrap();
5754 folded
5755 }
5756
5757 fn write_case_fixture(dir: &Path) {
5761 std::fs::write(dir.join("Foo.md"), "# Foo\n").unwrap();
5762 std::fs::create_dir_all(dir.join("Docs")).unwrap();
5763 std::fs::write(dir.join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5764 }
5765
5766 #[test]
5772 fn test_the_filesystem_answers_for_a_spelling_it_does_not_store() {
5773 let temp_dir = tempdir().unwrap();
5774 let anchor = temp_dir.path();
5775 write_case_fixture(anchor);
5776 reset_file_existence_cache();
5777
5778 assert_eq!(anchor.join("foo.md").exists(), volume_folds_case(anchor));
5779 assert!(!exists_exact_case(anchor, &anchor.join("foo.md")));
5780 }
5781
5782 #[test]
5783 fn test_only_the_stored_spelling_of_a_target_exists() {
5784 let temp_dir = tempdir().unwrap();
5785 let anchor = temp_dir.path();
5786 write_case_fixture(anchor);
5787 reset_file_existence_cache();
5788
5789 for target in ["Foo.md", "Docs/Guide.md", "Docs"] {
5790 assert!(
5791 exists_exact_case(anchor, &anchor.join(target)),
5792 "{target} is on disk under this spelling"
5793 );
5794 }
5795 for target in ["foo.md", "docs/Guide.md", "Docs/guide.md", "docs", "Bar.md"] {
5796 assert!(
5797 !exists_exact_case(anchor, &anchor.join(target)),
5798 "{target} is not on disk under this spelling"
5799 );
5800 }
5801 }
5802
5803 #[test]
5806 fn test_the_extension_fallback_keeps_the_case_of_the_target() {
5807 let temp_dir = tempdir().unwrap();
5808 let anchor = temp_dir.path();
5809 write_case_fixture(anchor);
5810 reset_file_existence_cache();
5811
5812 assert!(resolve_existing_target(anchor, &anchor.join("Foo")).is_some());
5813 assert!(resolve_existing_target(anchor, &anchor.join("foo")).is_none());
5814 }
5815
5816 #[test]
5819 fn test_a_target_reached_through_a_parent_step_is_checked() {
5820 let temp_dir = tempdir().unwrap();
5821 write_case_fixture(temp_dir.path());
5822 let anchor = temp_dir.path().join("Docs");
5823 reset_file_existence_cache();
5824
5825 assert!(exists_exact_case(&anchor, &anchor.join("..").join("Foo.md")));
5826 assert!(!exists_exact_case(&anchor, &anchor.join("..").join("foo.md")));
5827 }
5828
5829 #[test]
5832 fn test_a_component_above_the_anchor_is_accepted() {
5833 let temp_dir = tempdir().unwrap();
5834 write_case_fixture(temp_dir.path());
5835 let anchor = temp_dir.path().join("docs");
5836 reset_file_existence_cache();
5837
5838 assert!(has_exact_case_components(&anchor, &anchor.join("Guide.md")));
5839 }
5840
5841 #[cfg(unix)]
5845 #[test]
5846 fn test_a_link_through_a_symlinked_directory_exists() {
5847 let temp_dir = tempdir().unwrap();
5848 let anchor = temp_dir.path();
5849 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5850 std::fs::write(anchor.join("shared").join("Docs").join("Guide.md"), "# Guide\n").unwrap();
5851 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5852 reset_file_existence_cache();
5853
5854 assert!(exists_exact_case(anchor, &anchor.join("docs").join("Guide.md")));
5855 }
5856
5857 #[cfg(target_os = "macos")]
5860 #[test]
5861 fn test_a_decomposed_name_matches_a_composed_link() {
5862 let temp_dir = tempdir().unwrap();
5863 let anchor = temp_dir.path();
5864 std::fs::write(anchor.join("cafe\u{301}.md"), "# Cafe\n").unwrap();
5865 reset_file_existence_cache();
5866
5867 assert!(exists_exact_case(anchor, &anchor.join("caf\u{e9}.md")));
5868 assert!(!exists_exact_case(anchor, &anchor.join("CAF\u{c9}.md")));
5869 }
5870
5871 #[test]
5872 fn test_a_link_reports_every_spelling_the_filesystem_does_not_store() {
5873 let temp_dir = tempdir().unwrap();
5874 write_case_fixture(temp_dir.path());
5875 let content = "\
5876[a](foo.md)\n\
5877[b](docs/Guide.md)\n\
5878[c](foo)\n\
5879[d](docs)\n\
5880[e](Foo.md)\n\
5881[f](Docs/Guide.md)\n\
5882[g](Docs)\n\
5883[h](Bar.md)\n";
5884
5885 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5886
5887 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5888 assert_eq!(
5889 messages,
5890 vec![
5891 "Relative link 'foo.md' does not exist",
5892 "Relative link 'docs/Guide.md' does not exist",
5893 "Relative link 'foo' does not exist",
5894 "Relative link 'docs' does not exist",
5895 "Relative link 'Bar.md' does not exist",
5896 ],
5897 "Got: {result:?}"
5898 );
5899 }
5900
5901 #[test]
5904 fn test_the_reporters_link_is_reported() {
5905 let temp_dir = tempdir().unwrap();
5906 std::fs::write(temp_dir.path().join("changelog.md"), "").unwrap();
5907 let content = "# Test\n\nSee the [changelog](CHANGELOG.md).\n";
5908
5909 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5910
5911 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5912 assert_eq!(result[0].message, "Relative link 'CHANGELOG.md' does not exist");
5913 assert_eq!(result[0].line, 3);
5914 assert_eq!(result[0].column, 21);
5915 }
5916
5917 #[test]
5920 fn test_the_html_fallback_keeps_the_case_of_the_stem() {
5921 let temp_dir = tempdir().unwrap();
5922 std::fs::write(temp_dir.path().join("Guide.md"), "# Guide\n").unwrap();
5923 let content = "[a](Guide.html)\n[b](guide.html)\n";
5924
5925 let result = check_as_file(temp_dir.path(), "test.md", content, MD057Config::default());
5926
5927 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5928 assert_eq!(result[0].message, "Relative link 'guide.html' does not exist");
5929 assert_eq!(result[0].line, 2);
5930 }
5931
5932 #[test]
5935 fn test_a_search_path_answers_for_the_exact_spelling() {
5936 let temp_dir = tempdir().unwrap();
5937 let assets = temp_dir.path().join("assets");
5938 std::fs::create_dir_all(&assets).unwrap();
5939 std::fs::write(assets.join("Logo.png"), "").unwrap();
5940 let config = MD057Config {
5941 search_paths: vec![assets.to_string_lossy().into_owned()],
5942 ..Default::default()
5943 };
5944 let content = "[a](Logo.png)\n[b](logo.png)\n";
5945
5946 let result = check_as_file(temp_dir.path(), "test.md", content, config);
5947
5948 assert_eq!(result.len(), 1, "Expected one warning. Got: {result:?}");
5949 assert_eq!(result[0].message, "Relative link 'logo.png' does not exist");
5950 assert_eq!(result[0].line, 2);
5951 }
5952
5953 #[test]
5956 fn test_an_absolute_link_under_a_root_keeps_its_case() {
5957 let temp_dir = tempdir().unwrap();
5958 let root = temp_dir.path();
5959 write_case_fixture(root);
5960 let content = "[a](/Docs/Guide.md)\n[b](/docs/Guide.md)\n[c](/Docs)\n[d](/docs)\n";
5961
5962 let config = MD057Config {
5963 absolute_links: AbsoluteLinksOption::RelativeToRoots,
5964 roots: vec![],
5965 ..Default::default()
5966 };
5967 let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(root);
5968 let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
5969 let result = rule.check(&ctx).unwrap();
5970
5971 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
5972 assert_eq!(
5973 messages,
5974 vec![
5975 "Absolute link '/docs/Guide.md' was not found under the project root",
5976 "Absolute link '/docs' was not found under the project root",
5977 ],
5978 "Got: {result:?}"
5979 );
5980 }
5981
5982 #[cfg(unix)]
5987 #[test]
5988 fn test_a_parent_step_is_resolved_lexically_through_a_symlink() {
5989 let temp_dir = tempdir().unwrap();
5990 let anchor = temp_dir.path();
5991 std::fs::create_dir_all(anchor.join("shared").join("Docs")).unwrap();
5992 std::fs::write(anchor.join("shared").join("Foo.md"), "# Foo\n").unwrap();
5993 std::fs::write(anchor.join("shared").join("Docs").join("Bar.md"), "# Bar\n").unwrap();
5994 std::os::unix::fs::symlink("shared/Docs", anchor.join("docs")).unwrap();
5995 let content = "[a](docs/../Foo.md)\n[b](docs/Bar.md)\n[c](docs/bar.md)\n";
5996
5997 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
5998
5999 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6000 assert_eq!(
6001 messages,
6002 vec![
6003 "Relative link 'docs/../Foo.md' does not exist",
6004 "Relative link 'docs/bar.md' does not exist",
6005 ],
6006 "Got: {result:?}"
6007 );
6008 }
6009
6010 #[cfg(unix)]
6015 struct RestoreMode {
6016 directory: PathBuf,
6017 mode: u32,
6018 }
6019
6020 #[cfg(unix)]
6021 impl RestoreMode {
6022 fn take(directory: &Path) -> Self {
6023 use std::os::unix::fs::PermissionsExt;
6024 Self {
6025 directory: directory.to_path_buf(),
6026 mode: std::fs::metadata(directory).unwrap().permissions().mode(),
6027 }
6028 }
6029 }
6030
6031 #[cfg(unix)]
6032 impl Drop for RestoreMode {
6033 fn drop(&mut self) {
6034 use std::os::unix::fs::PermissionsExt;
6035 let _ = std::fs::set_permissions(&self.directory, std::fs::Permissions::from_mode(self.mode));
6036 }
6037 }
6038
6039 #[cfg(unix)]
6043 #[test]
6044 fn test_a_directory_that_cannot_be_listed_is_accepted() {
6045 use std::os::unix::fs::PermissionsExt;
6046
6047 let temp_dir = tempdir().unwrap();
6048 let anchor = temp_dir.path();
6049 let locked = anchor.join("locked");
6050 std::fs::create_dir_all(&locked).unwrap();
6051 std::fs::write(locked.join("Target.md"), "# Target\n").unwrap();
6052
6053 let _restore = RestoreMode::take(&locked);
6054 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o111)).unwrap();
6055 if std::fs::read_dir(&locked).is_ok() {
6056 return;
6059 }
6060 assert!(locked.join("Target.md").exists(), "the directory can still be entered");
6061 let content = "[a](locked/Target.md)\n";
6062
6063 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6064
6065 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6066 }
6067
6068 #[cfg(target_os = "macos")]
6072 #[test]
6073 fn test_a_composed_name_matches_a_decomposed_link() {
6074 let temp_dir = tempdir().unwrap();
6075 let anchor = temp_dir.path();
6076 std::fs::write(anchor.join("caf\u{e9}.md"), "# Cafe\n").unwrap();
6077 reset_file_existence_cache();
6078
6079 assert!(exists_exact_case(anchor, &anchor.join("cafe\u{301}.md")));
6080 assert!(!exists_exact_case(anchor, &anchor.join("CAFE\u{301}.md")));
6081
6082 let content = "[a](cafe\u{301}.md)\n[b](CAFE\u{301}.md)\n";
6083
6084 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6085
6086 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6087 assert_eq!(
6088 messages,
6089 vec!["Relative link 'CAFE\u{301}.md' does not exist"],
6090 "Got: {result:?}"
6091 );
6092 }
6093
6094 #[cfg(target_os = "macos")]
6099 #[test]
6100 fn test_a_name_composing_to_ascii_matches_an_ascii_link() {
6101 let temp_dir = tempdir().unwrap();
6102 let anchor = temp_dir.path();
6103 std::fs::write(anchor.join("\u{212a}elvin.md"), "# Kelvin\n").unwrap();
6104 reset_file_existence_cache();
6105
6106 assert!(exists_exact_case(anchor, &anchor.join("Kelvin.md")));
6107 assert!(!exists_exact_case(anchor, &anchor.join("kelvin.md")));
6108
6109 let content = "[a](Kelvin.md)\n[b](kelvin.md)\n";
6110
6111 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6112
6113 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6114 assert_eq!(
6115 messages,
6116 vec!["Relative link 'kelvin.md' does not exist"],
6117 "Got: {result:?}"
6118 );
6119 }
6120
6121 #[cfg(target_os = "macos")]
6125 #[test]
6126 fn test_an_ascii_name_matches_a_link_composing_to_it() {
6127 let temp_dir = tempdir().unwrap();
6128 let anchor = temp_dir.path();
6129 std::fs::write(anchor.join("Kelvin.md"), "# Kelvin\n").unwrap();
6130 reset_file_existence_cache();
6131
6132 assert!(exists_exact_case(anchor, &anchor.join("\u{212a}elvin.md")));
6133 assert!(!exists_exact_case(anchor, &anchor.join("\u{212a}ELVIN.md")));
6134
6135 let content = "[a](\u{212a}elvin.md)\n[b](\u{212a}ELVIN.md)\n";
6136
6137 let result = check_as_file(anchor, "test.md", content, MD057Config::default());
6138
6139 let messages: Vec<&str> = result.iter().map(|warning| warning.message.as_str()).collect();
6140 assert_eq!(
6141 messages,
6142 vec!["Relative link '\u{212a}ELVIN.md' does not exist"],
6143 "Got: {result:?}"
6144 );
6145 }
6146
6147 fn seed_listing(directory: &Path, modified: SystemTime) {
6155 let listing = DirectoryListing {
6156 modified: Some(modified),
6157 listed: true,
6158 names: HashSet::from([OsString::from("Other.md")]),
6159 composed: HashSet::new(),
6160 };
6161 let key = std::fs::canonicalize(directory).unwrap();
6162 DIRECTORY_LISTING_CACHE.lock().unwrap().insert(key, Arc::new(listing));
6163 }
6164
6165 #[test]
6170 fn test_a_listing_answers_until_the_directory_changes() {
6171 let temp_dir = tempdir().unwrap();
6172 let anchor = temp_dir.path();
6173 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6174 std::fs::write(anchor.join("test.md"), "").unwrap();
6177 let read_at = std::fs::metadata(anchor).unwrap().modified().unwrap();
6178 seed_listing(anchor, read_at);
6179
6180 let reported = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6181
6182 let messages: Vec<&str> = reported.iter().map(|warning| warning.message.as_str()).collect();
6183 assert_eq!(
6184 messages,
6185 vec!["Relative link 'README.md' does not exist"],
6186 "the seeded listing answers while the directory's time stands. Got: {reported:?}"
6187 );
6188
6189 std::fs::write(anchor.join("Another.md"), "# Another\n").unwrap();
6190 assert_ne!(
6191 std::fs::metadata(anchor).unwrap().modified().unwrap(),
6192 read_at,
6193 "creating an entry moves the directory's modification time"
6194 );
6195
6196 let accepted = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6197
6198 assert!(
6199 accepted.is_empty(),
6200 "the listing is read again once the directory has moved on. Got: {accepted:?}"
6201 );
6202 }
6203
6204 #[test]
6208 fn test_a_listing_from_another_moment_is_discarded() {
6209 let temp_dir = tempdir().unwrap();
6210 let anchor = temp_dir.path();
6211 std::fs::write(anchor.join("README.md"), "# Readme\n").unwrap();
6212 seed_listing(anchor, SystemTime::UNIX_EPOCH);
6213
6214 let result = check_as_file(anchor, "test.md", "[a](README.md)\n", MD057Config::default());
6215
6216 assert!(result.is_empty(), "Expected no warning. Got: {result:?}");
6217 }
6218}