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